Skip to main content

omgbase_surface/
catalog.rs

1//! The MCP tool catalog (`spec/surface/README.md` §4) as a library: a table
2//! of [`ToolSpec`]s (name, JSON-schema input, description) and one dispatch
3//! ([`Surface::call`]) that runs a tool against the store and returns its
4//! JSON result or the error envelope. Transport-agnostic — a server wraps
5//! each outcome in one text content item. Port of
6//! `packages/core/src/mcp/server.ts`.
7//!
8//! Paths (`spec/surface` §1 "Paths", 2.0): every path a tool returns is the
9//! reference form (`/a/b.md`) and every path argument accepts either form.
10//! The surface's own readers root their results; the results another spec
11//! pins in the storage form — the store's apply / document-operation /
12//! observe outcomes, opsets, change digests, search hits — are re-shaped
13//! here, exactly as the reference's `surface-paths.ts` re-shapes them.
14
15use std::path::Path;
16
17use omgbase_format::BlockKind;
18use omgbase_format::text::{normalize_text, normalize_visible_text};
19use omgbase_reconcile::Config;
20use omgbase_search::EmbeddingProvider;
21use omgbase_store::docs_ops::{canonical, retargeted_json};
22use omgbase_store::links::inbound_links_to;
23use omgbase_store::mutate_kernel::effects::{Admission, EffectExpect, Target as EffectTarget};
24use omgbase_store::mutate_kernel::{At, ErrorCode, Parent};
25use omgbase_store::{
26    ApplyOrigin, DocStore, Expect, FsDocStore, MutationError, Plan, PlanContext, PublicEffect,
27    QueryVector, Receipt, Retargeted, Store, sugar,
28};
29use omgbase_sync::{RealFileSystem, RepoRow};
30use rusqlite::{OptionalExtension, params};
31use serde_json::{Map, Value as Json, json};
32
33use crate::arguments::{argument_error, validate_arguments};
34use crate::error::{Result, SurfaceError};
35use crate::graph::{GraphArgs, graph_neighborhood};
36use crate::history;
37use crate::links;
38use crate::paths::{reference_keyed, reference_path, root_field, storage_path};
39use crate::query::{QueryOptions, query};
40use crate::read::{self, Resolution, ResolvedRef};
41use crate::reference::QUERY_SYNTAX;
42use crate::version::{BuildInfo, version_info};
43
44/// The actor every write from this surface records.
45pub const ACTOR: &str = "agent:mcp";
46
47/// One tool of the catalog.
48#[derive(Clone, Debug, PartialEq)]
49pub struct ToolSpec {
50    pub name: &'static str,
51    pub description: &'static str,
52    /// The JSON schema of the arguments object.
53    pub input_schema: Json,
54}
55
56/// What a tool call produced: its JSON result, or the error envelope with
57/// `is_error` set.
58#[derive(Clone, Debug, PartialEq)]
59pub struct ToolOutcome {
60    pub body: Json,
61    pub is_error: bool,
62}
63
64/// Where a mutating tool writes files.
65enum WriteTarget {
66    /// Derive the repo's root from its `fs` source and write through the
67    /// filesystem (a sourceless repo cannot mutate).
68    Derived,
69    /// Write through this store whatever the repo's sources say (a runner's
70    /// in-memory store).
71    Fixed(Box<dyn DocStore>),
72}
73
74/// The catalog bound to a store, a default repo and optional providers.
75pub struct Surface {
76    store: Store,
77    default_repo: String,
78    provider: Option<Box<dyn EmbeddingProvider>>,
79    /// The configured provider's name (`embedding.provider`), for the
80    /// `provider_unavailable` error's `data` when the host knows it.
81    provider_name: Option<String>,
82    on_mutation: Option<Box<dyn FnMut()>>,
83    clock: Box<dyn FnMut() -> String>,
84    writes: WriteTarget,
85    config: Config,
86    /// The actor every write records (`ACTOR` unless a client names one).
87    actor: String,
88    /// What the serving binary knows about itself (the `version` tool).
89    build_info: BuildInfo,
90    /// [`Surface::preview_call`] is running: a sugar tool plans its effects
91    /// and answers with the Plan instead of committing (the CLI's local
92    /// `--dry-run`, `spec/cli` §3.6). Never set by a tool call.
93    preview: bool,
94    /// The Plan the last sugar tool or `apply` planned (its preview realizes
95    /// the committed ids through the receipt's `new_ids`), for
96    /// [`Surface::call_result_with_plan`].
97    last_plan: Option<Plan>,
98}
99
100// ---- argument helpers ----------------------------------------------------------------
101
102fn bad_args(msg: impl Into<String>) -> SurfaceError {
103    SurfaceError::filter_invalid(msg, "arguments")
104}
105
106fn arg_str<'a>(args: &'a Json, key: &str) -> Option<&'a str> {
107    args.get(key).and_then(Json::as_str)
108}
109
110fn arg_string(args: &Json, key: &str) -> Result<String> {
111    arg_str(args, key)
112        .map(str::to_owned)
113        .ok_or_else(|| bad_args(format!("`{key}` must be a string")))
114}
115
116fn arg_i64(args: &Json, key: &str) -> Result<Option<i64>> {
117    match args.get(key) {
118        None | Some(Json::Null) => Ok(None),
119        Some(v) => v
120            .as_f64()
121            .filter(|n| n.fract() == 0.0)
122            .map(|n| Some(n as i64))
123            .ok_or_else(|| bad_args(format!("`{key}` must be an integer"))),
124    }
125}
126
127fn arg_usize(args: &Json, key: &str) -> Result<Option<usize>> {
128    Ok(arg_i64(args, key)?.map(|n| usize::try_from(n).unwrap_or(0)))
129}
130
131fn arg_bool(args: &Json, key: &str) -> Result<Option<bool>> {
132    match args.get(key) {
133        None | Some(Json::Null) => Ok(None),
134        Some(Json::Bool(b)) => Ok(Some(*b)),
135        Some(_) => Err(bad_args(format!("`{key}` must be a boolean"))),
136    }
137}
138
139fn arg_strings(args: &Json, key: &str) -> Result<Vec<String>> {
140    let Some(arr) = args.get(key).and_then(Json::as_array) else {
141        return Err(bad_args(format!("`{key}` must be an array of strings")));
142    };
143    arr.iter()
144        .map(|v| {
145            v.as_str()
146                .map(str::to_owned)
147                .ok_or_else(|| bad_args(format!("`{key}` must be an array of strings")))
148        })
149        .collect()
150}
151
152fn arg_object<'a>(args: &'a Json, key: &str) -> Result<Option<&'a Map<String, Json>>> {
153    match args.get(key) {
154        None | Some(Json::Null) => Ok(None),
155        Some(Json::Object(o)) => Ok(Some(o)),
156        Some(_) => Err(bad_args(format!("`{key}` must be an object"))),
157    }
158}
159
160fn hex_field(e: &Map<String, Json>, k: &str) -> Option<String> {
161    e.get(k).and_then(Json::as_str).map(str::to_owned)
162}
163
164/// A block tool's `expect { content_hash?, parent_children_hash? }` (the
165/// reference's `expectSchema`, both keys optional), or `None` when absent.
166fn arg_expect(args: &Json) -> Result<Option<Expect>> {
167    Ok(arg_object(args, "expect")?.map(|e| Expect {
168        content_hash: hex_field(e, "content_hash"),
169        parent_children_hash: hex_field(e, "parent_children_hash"),
170    }))
171}
172
173/// `blocks_insert` / `blocks_move`'s `expect { parent_children_hash? }` — the
174/// destination-parent CAS of `spec/mutate` §1.2 (1.2). A `content_hash` has
175/// no meaning on these ops and is dropped, not an error (the reference's zod
176/// schema strips it).
177fn arg_parent_expect(args: &Json) -> Result<Option<Expect>> {
178    Ok(arg_object(args, "expect")?.map(|e| Expect {
179        content_hash: None,
180        parent_children_hash: hex_field(e, "parent_children_hash"),
181    }))
182}
183
184fn resolution_arg(args: &Json, default: Resolution) -> Result<Resolution> {
185    match arg_str(args, "resolution") {
186        None => Ok(default),
187        Some(s) => Resolution::parse(s).ok_or_else(|| {
188            bad_args("`resolution` must be one of skeleton, outline, text, raw, full")
189        }),
190    }
191}
192
193/// `{ ...a, ...b }` (b wins).
194fn merge(mut a: Map<String, Json>, b: Json) -> Json {
195    if let Json::Object(o) = b {
196        for (k, v) in o {
197            a.insert(k, v);
198        }
199    }
200    Json::Object(a)
201}
202
203// ---- the sugar tools over effects (ADR-021; `spec/surface` §4, 4.0) ---------------------
204//
205// Every write tool but `apply`/`plan`/`execute` and the observe tools is
206// sugar over `apply { effects }`: each handler keeps its own pre-checks (so
207// every pinned error `data` is unchanged), builds its effects with the shared
208// builders (`omgbase_store::sugar`, the reference's `mutate/sugar.ts`) and
209// hands them to [`Surface::apply_sugar`], which plans and commits them in one
210// transaction under the tool's name as the commit `reason` and answers with
211// the Receipt. There is no `dry_run` (4.0): the preview is `plan`.
212
213/// An empty block list is an argument error (3.2, §9): `filter_invalid`
214/// "`blocks` must name at least one block (… takes …)" — it used to reach
215/// the kernel as a malformed op (`block_missing "block undefined not
216/// found"`), or commit nothing and report success (`blocks_check`). The
217/// reference's `requireSome`, raised after the root check and before any
218/// ref resolves.
219fn require_some(tool: &str, arg: &str, list: Vec<String>, spec: &ToolSpec) -> Result<Vec<String>> {
220    if list.is_empty() {
221        return Err(argument_error(
222            tool,
223            &format!("`{arg}` must name at least one block"),
224            &spec.input_schema,
225        ));
226    }
227    Ok(list)
228}
229
230/// A block tool's kernel `expect` as an effect's (`revision` is never set
231/// by the sugar tools).
232fn effect_expect(e: Option<Expect>) -> Option<EffectExpect> {
233    e.map(|e| EffectExpect {
234        content_hash: e.content_hash,
235        parent_children_hash: e.parent_children_hash,
236        revision: None,
237    })
238}
239
240/// A resolved placement parent as the effect target it names.
241fn placement_target(parent: Parent, doc_id: String) -> EffectTarget {
242    match parent {
243        Parent::Doc => EffectTarget::Document {
244            id: Some(doc_id),
245            path: None,
246        },
247        Parent::Block(id) => EffectTarget::Block { id },
248        Parent::Section { heading } => EffectTarget::Section {
249            heading_id: heading,
250        },
251    }
252}
253
254/// What [`Surface::apply_sugar_with`] hands a sugar tool: the plan and, unless
255/// the surface is previewing, the receipt.
256type SugarOutcome = (Plan, Option<Receipt>);
257
258/// The planner stamps `effect_index` on every error it raises; a sugar
259/// tool's caller never passed effects, so the key goes (the kernel's own
260/// `op_index`, `block`, `current`… stay as pinned).
261fn strip_effect_index(e: omgbase_store::Error) -> omgbase_store::Error {
262    match e {
263        omgbase_store::Error::Mutation(mut m) => {
264            m.data.remove("effect_index");
265            omgbase_store::Error::Mutation(m)
266        }
267        other => other,
268    }
269}
270
271/// A sugar tool's answer: the Receipt (§4, 4.0), or — previewing — the Plan.
272fn sugar_json((plan, receipt): &SugarOutcome) -> Json {
273    match receipt {
274        Some(r) => receipt_json(r),
275        None => plan_json(plan),
276    }
277}
278
279fn schema(props: &[(&str, Json)], required: &[&str], repo: bool) -> Json {
280    let mut p = Map::new();
281    for (k, v) in props {
282        p.insert((*k).to_owned(), v.clone());
283    }
284    if repo {
285        p.insert("repo".to_owned(), json!({ "type": "string" }));
286    }
287    json!({ "type": "object", "properties": p, "required": required })
288}
289
290fn s() -> Json {
291    json!({ "type": "string" })
292}
293fn i() -> Json {
294    json!({ "type": "integer" })
295}
296fn b() -> Json {
297    json!({ "type": "boolean" })
298}
299fn strings() -> Json {
300    json!({ "type": "array", "items": { "type": "string" } })
301}
302fn obj() -> Json {
303    json!({ "type": "object" })
304}
305fn nullable_string() -> Json {
306    json!({ "type": ["string", "null"] })
307}
308fn resolution_schema() -> Json {
309    json!({ "type": "string", "enum": ["skeleton", "outline", "text", "raw", "full"] })
310}
311fn at_schema() -> Json {
312    json!({ "oneOf": [
313        { "const": "start" }, { "const": "end" },
314        { "type": "object", "properties": { "before": { "type": "string" } }, "required": ["before"] },
315        { "type": "object", "properties": { "after": { "type": "string" } }, "required": ["after"] }
316    ] })
317}
318fn expect_schema() -> Json {
319    json!({ "type": "object", "properties": { "content_hash": { "type": "string" }, "parent_children_hash": { "type": "string" } } })
320}
321/// `blocks_insert` / `blocks_move` carry the op-level expectation of
322/// `spec/mutate` §1.2: only `parent_children_hash` has a meaning there (no
323/// target block whose content could be checked), so the schema names that
324/// key alone, as the reference's `parentExpectSchema`.
325fn effects_schema() -> Json {
326    json!({ "type": "array", "items": { "type": "object" } })
327}
328
329fn parent_expect_schema() -> Json {
330    json!({ "type": "object", "properties": { "parent_children_hash": { "type": "string" } } })
331}
332
333/// The catalog (§4 table), in the reference's registration order. The
334/// descriptions are the reference's, byte for byte, generated into
335/// [`descriptions`](crate::descriptions) from `spec/surface/tool-descriptions.json`
336/// (`scripts/gen-tool-descriptions.mjs`); `tests/spec.rs` pins the parity.
337#[must_use]
338pub fn tools() -> Vec<ToolSpec> {
339    catalog().to_vec()
340}
341
342/// The catalog, built once.
343pub(crate) fn catalog() -> &'static [ToolSpec] {
344    static CATALOG: std::sync::OnceLock<Vec<ToolSpec>> = std::sync::OnceLock::new();
345    CATALOG.get_or_init(build_catalog)
346}
347
348/// The spec of one tool, by name.
349pub(crate) fn tool_spec(name: &str) -> Option<&'static ToolSpec> {
350    catalog().iter().find(|t| t.name == name)
351}
352
353#[allow(clippy::too_many_lines)]
354fn build_catalog() -> Vec<ToolSpec> {
355    use crate::descriptions as d;
356    // The document locator every `docs_*` tool takes (§4 "Arguments"): the
357    // row's own attributes, at least one of them (checked by the handler).
358    let doc = |extra: &[(&str, Json)], required: &[&str]| {
359        let mut props = vec![("id", s()), ("path", s())];
360        props.extend(extra.iter().cloned());
361        schema(&props, required, true)
362    };
363    vec![
364        ToolSpec {
365            name: "docs_outline",
366            description: d::DOCS_OUTLINE,
367            input_schema: doc(
368                &[
369                    (
370                        "resolution",
371                        json!({ "type": "string", "enum": ["skeleton", "outline"] }),
372                    ),
373                    ("depth", i()),
374                    ("budget_tokens", i()),
375                ],
376                &[],
377            ),
378        },
379        ToolSpec {
380            name: "docs_read",
381            description: d::DOCS_READ,
382            input_schema: doc(&[("include_ids", b())], &[]),
383        },
384        ToolSpec {
385            name: "docs_read_many",
386            description: d::DOCS_READ_MANY,
387            input_schema: schema(
388                &[
389                    ("docs", strings()),
390                    ("include_ids", b()),
391                    ("budget_tokens", i()),
392                ],
393                &["docs"],
394                true,
395            ),
396        },
397        ToolSpec {
398            name: "blocks_read",
399            description: d::BLOCKS_READ,
400            input_schema: schema(
401                &[("id", s()), ("resolution", resolution_schema())],
402                &["id"],
403                true,
404            ),
405        },
406        ToolSpec {
407            name: "nodes_block_read",
408            description: d::NODES_BLOCK_READ,
409            input_schema: schema(
410                &[("id", s()), ("resolution", resolution_schema())],
411                &["id"],
412                true,
413            ),
414        },
415        ToolSpec {
416            name: "blocks_read_many",
417            description: d::BLOCKS_READ_MANY,
418            input_schema: schema(
419                &[
420                    ("ids", strings()),
421                    ("resolution", resolution_schema()),
422                    ("budget_tokens", i()),
423                ],
424                &["ids"],
425                true,
426            ),
427        },
428        ToolSpec {
429            name: "docs_tree",
430            description: d::DOCS_TREE,
431            input_schema: schema(
432                &[
433                    ("path", s()),
434                    ("depth", i()),
435                    ("limit", i()),
436                    ("cursor", nullable_string()),
437                    ("budget_tokens", i()),
438                ],
439                &[],
440                true,
441            ),
442        },
443        ToolSpec {
444            name: "docs_list",
445            description: d::DOCS_LIST,
446            input_schema: schema(
447                &[
448                    ("path_glob", s()),
449                    ("limit", i()),
450                    ("cursor", nullable_string()),
451                    ("budget_tokens", i()),
452                ],
453                &[],
454                true,
455            ),
456        },
457        ToolSpec {
458            name: "query_syntax",
459            description: d::QUERY_SYNTAX,
460            input_schema: schema(&[], &[], false),
461        },
462        ToolSpec {
463            name: "query",
464            description: d::QUERY,
465            input_schema: schema(
466                &[
467                    ("query", s()),
468                    ("limit", i()),
469                    ("cursor", nullable_string()),
470                ],
471                &["query"],
472                true,
473            ),
474        },
475        ToolSpec {
476            name: "graph",
477            description: d::GRAPH,
478            input_schema: schema(
479                &[
480                    ("roots", strings()),
481                    ("degrees", i()),
482                    (
483                        "direction",
484                        json!({ "type": "string", "enum": ["in", "out", "both"] }),
485                    ),
486                    ("predicate", s()),
487                    ("select", strings()),
488                    ("max_documents", i()),
489                ],
490                &["roots"],
491                true,
492            ),
493        },
494        ToolSpec {
495            name: "blocks_search",
496            description: d::BLOCKS_SEARCH,
497            input_schema: schema(&[("q", s()), ("limit", i())], &["q"], true),
498        },
499        ToolSpec {
500            name: "blocks_resolve",
501            description: d::BLOCKS_RESOLVE,
502            input_schema: schema(&[("query", s()), ("limit", i())], &["query"], true),
503        },
504        ToolSpec {
505            name: "apply",
506            description: d::APPLY,
507            input_schema: schema(
508                &[("effects", effects_schema()), ("reason", s())],
509                &["effects"],
510                true,
511            ),
512        },
513        ToolSpec {
514            name: "plan",
515            description: d::PLAN,
516            input_schema: schema(&[("effects", effects_schema())], &["effects"], true),
517        },
518        ToolSpec {
519            name: "execute",
520            description: d::EXECUTE,
521            input_schema: schema(
522                &[
523                    ("plan", obj()),
524                    ("plan_id", s()),
525                    ("effects", effects_schema()),
526                    ("reason", s()),
527                ],
528                &[],
529                true,
530            ),
531        },
532        ToolSpec {
533            name: "blocks_insert",
534            description: d::BLOCKS_INSERT,
535            input_schema: schema(
536                &[
537                    ("to", s()),
538                    ("markdown", s()),
539                    ("at", at_schema()),
540                    ("expect", parent_expect_schema()),
541                ],
542                &["to", "markdown"],
543                true,
544            ),
545        },
546        ToolSpec {
547            name: "blocks_update",
548            description: d::BLOCKS_UPDATE,
549            input_schema: schema(
550                &[
551                    ("id", s()),
552                    ("markdown", s()),
553                    ("checked", b()),
554                    ("expect", expect_schema()),
555                ],
556                &["id"],
557                true,
558            ),
559        },
560        ToolSpec {
561            name: "blocks_move",
562            description: d::BLOCKS_MOVE,
563            input_schema: schema(
564                &[
565                    ("blocks", strings()),
566                    ("to", s()),
567                    ("at", at_schema()),
568                    ("expect", parent_expect_schema()),
569                ],
570                &["blocks", "to"],
571                true,
572            ),
573        },
574        ToolSpec {
575            name: "blocks_remove",
576            description: d::BLOCKS_REMOVE,
577            input_schema: schema(&[("blocks", strings())], &["blocks"], true),
578        },
579        ToolSpec {
580            name: "blocks_split",
581            description: d::BLOCKS_SPLIT,
582            input_schema: schema(
583                &[
584                    ("id", s()),
585                    (
586                        "at",
587                        json!({ "type": "array", "items": { "type": "integer" } }),
588                    ),
589                ],
590                &["id", "at"],
591                true,
592            ),
593        },
594        ToolSpec {
595            name: "blocks_merge",
596            description: d::BLOCKS_MERGE,
597            input_schema: schema(
598                &[("blocks", strings()), ("separator", s())],
599                &["blocks"],
600                true,
601            ),
602        },
603        ToolSpec {
604            name: "blocks_check",
605            description: d::BLOCKS_CHECK,
606            input_schema: schema(&[("ids", strings()), ("checked", b())], &["ids"], true),
607        },
608        ToolSpec {
609            name: "nodes_set",
610            description: d::NODES_SET,
611            input_schema: schema(
612                &[("id", s()), ("prop", s()), ("value", s())],
613                &["id", "prop", "value"],
614                true,
615            ),
616        },
617        ToolSpec {
618            name: "blocks_append",
619            description: d::BLOCKS_APPEND,
620            input_schema: schema(&[("id", s()), ("text", s())], &["id", "text"], true),
621        },
622        ToolSpec {
623            name: "docs_append",
624            description: d::DOCS_APPEND,
625            input_schema: doc(&[("text", s()), ("section", s())], &["text"]),
626        },
627        ToolSpec {
628            name: "links_retarget",
629            description: d::LINKS_RETARGET,
630            input_schema: schema(
631                &[("from_target", s()), ("to_target", s()), ("path_glob", s())],
632                &["from_target", "to_target"],
633                true,
634            ),
635        },
636        ToolSpec {
637            name: "links_stale",
638            description: d::LINKS_STALE,
639            input_schema: schema(
640                &[("path_glob", s()), ("limit", i()), ("summary", b())],
641                &[],
642                true,
643            ),
644        },
645        ToolSpec {
646            name: "links_repair",
647            description: d::LINKS_REPAIR,
648            input_schema: schema(
649                &[
650                    (
651                        "repairs",
652                        json!({ "type": "array", "items": { "type": "object", "properties": { "from": { "type": "string" }, "to": { "type": "string" } }, "required": ["from", "to"] } }),
653                    ),
654                    ("from_target", s()),
655                    ("to_target", s()),
656                    ("path_glob", s()),
657                ],
658                &[],
659                true,
660            ),
661        },
662        ToolSpec {
663            name: "docs_create",
664            description: d::DOCS_CREATE,
665            input_schema: schema(
666                &[("path", s()), ("markdown", s()), ("frontmatter", obj())],
667                &["path", "markdown"],
668                true,
669            ),
670        },
671        ToolSpec {
672            name: "docs_move",
673            description: d::DOCS_MOVE,
674            input_schema: doc(&[("to_path", s()), ("retarget_inbound", b())], &["to_path"]),
675        },
676        ToolSpec {
677            name: "docs_delete",
678            description: d::DOCS_DELETE,
679            input_schema: doc(&[], &[]),
680        },
681        ToolSpec {
682            name: "docs_set_meta",
683            description: d::DOCS_SET_META,
684            input_schema: doc(&[("set", obj()), ("unset", strings())], &[]),
685        },
686        ToolSpec {
687            name: "docs_update",
688            description: d::DOCS_UPDATE,
689            input_schema: doc(&[("content", s()), ("reason", s())], &["content"]),
690        },
691        ToolSpec {
692            name: "observe",
693            description: d::OBSERVE,
694            input_schema: schema(
695                &[("path", s()), ("content", s())],
696                &["path", "content"],
697                true,
698            ),
699        },
700        ToolSpec {
701            name: "observe_many",
702            description: d::OBSERVE_MANY,
703            input_schema: schema(
704                &[(
705                    "files",
706                    json!({ "type": "array", "items": { "type": "object", "properties": { "path": { "type": "string" }, "content": { "type": "string" } }, "required": ["path", "content"] } }),
707                )],
708                &["files"],
709                true,
710            ),
711        },
712        ToolSpec {
713            name: "observe_delete",
714            description: d::OBSERVE_DELETE,
715            input_schema: schema(&[("path", s())], &["path"], true),
716        },
717        ToolSpec {
718            name: "blocks_history",
719            description: d::BLOCKS_HISTORY,
720            input_schema: schema(&[("id", s()), ("limit", i())], &["id"], false),
721        },
722        ToolSpec {
723            name: "docs_diff",
724            description: d::DOCS_DIFF,
725            input_schema: doc(
726                &[("from_rev", s()), ("to_rev", s())],
727                &["from_rev", "to_rev"],
728            ),
729        },
730        ToolSpec {
731            name: "docs_diff_unified",
732            description: d::DOCS_DIFF_UNIFIED,
733            input_schema: doc(&[("from_rev", s()), ("to_rev", s())], &[]),
734        },
735        ToolSpec {
736            name: "docs_read_at",
737            description: d::DOCS_READ_AT,
738            input_schema: doc(&[("rev", s())], &["rev"]),
739        },
740        ToolSpec {
741            name: "docs_history",
742            description: d::DOCS_HISTORY,
743            input_schema: schema(
744                &[
745                    ("path_glob", s()),
746                    ("id", s()),
747                    ("path", s()),
748                    ("include_deleted", b()),
749                    ("limit", i()),
750                ],
751                &[],
752                true,
753            ),
754        },
755        ToolSpec {
756            name: "changes_since",
757            description: d::CHANGES_SINCE,
758            input_schema: schema(
759                &[
760                    ("cursor", i()),
761                    (
762                        "origin",
763                        json!({ "type": "string", "enum": ["api", "observed", "import"] }),
764                    ),
765                    ("limit", i()),
766                ],
767                &[],
768                true,
769            ),
770        },
771        ToolSpec {
772            name: "repos_status",
773            description: d::REPOS_STATUS,
774            input_schema: schema(&[], &[], true),
775        },
776        ToolSpec {
777            name: "sync_status",
778            description: d::SYNC_STATUS,
779            input_schema: schema(&[], &[], true),
780        },
781        ToolSpec {
782            name: "repos",
783            description: d::REPOS,
784            input_schema: schema(&[], &[], false),
785        },
786        ToolSpec {
787            name: "version",
788            description: d::VERSION,
789            input_schema: schema(&[], &[], false),
790        },
791    ]
792}
793
794impl Surface {
795    /// A surface over `store` whose default repo is `default_repo` (an id).
796    /// Mutations derive each repo's root from its `fs` source.
797    #[must_use]
798    pub fn new(
799        store: Store,
800        default_repo: &str,
801        provider: Option<Box<dyn EmbeddingProvider>>,
802    ) -> Self {
803        // Read-path tuning of the surface's own connection; neither pragma
804        // changes a result. The reference runs on better-sqlite3, whose
805        // bundled SQLite is compiled with a 16 MB default page cache
806        // (`SQLITE_DEFAULT_CACHE_SIZE=-16000`); rusqlite's bundled build
807        // keeps the stock 2 MB, so the same root scans and pushed
808        // predicates over a database of tens of MB miss the cache and
809        // `pread` page by page. `temp_store=MEMORY` keeps the sorter of a
810        // `… ORDER BY d.path, b.block_id` scan (tens of thousands of rows)
811        // from spilling its runs to temp files. A failure here only leaves
812        // the connection untuned.
813        let _ = store
814            .conn()
815            .execute_batch("PRAGMA cache_size = -16000; PRAGMA temp_store = MEMORY;");
816        Self {
817            store,
818            default_repo: default_repo.to_owned(),
819            provider,
820            provider_name: None,
821            on_mutation: None,
822            clock: Box::new(omgbase_sync::now_ts),
823            writes: WriteTarget::Derived,
824            config: Config::default(),
825            actor: ACTOR.to_owned(),
826            build_info: BuildInfo::default(),
827            preview: false,
828            last_plan: None,
829        }
830    }
831
832    /// Name the configured embedding provider (`embedding.provider`) so a
833    /// `provider_unavailable` error can say which one cannot rank.
834    #[must_use]
835    pub fn with_provider_name(mut self, name: &str) -> Self {
836        self.provider_name = Some(name.to_owned());
837        self
838    }
839
840    /// Record `actor` on every write instead of [`ACTOR`] (the CLI's
841    /// `human:<user>` / `--actor`; the MCP server keeps the default).
842    #[must_use]
843    pub fn with_actor(mut self, actor: &str) -> Self {
844        self.set_actor(actor);
845        self
846    }
847
848    /// Change the actor of the writes that follow.
849    pub fn set_actor(&mut self, actor: &str) {
850        self.actor = actor.to_owned();
851    }
852
853    /// Route every mutation's file writes through `doc_store` regardless of
854    /// the repo's sources (a runner's in-memory store).
855    #[must_use]
856    pub fn with_doc_store(mut self, doc_store: Box<dyn DocStore>) -> Self {
857        self.writes = WriteTarget::Fixed(doc_store);
858        self
859    }
860
861    /// Stamp commits with `clock()` instead of the wall clock.
862    #[must_use]
863    pub fn with_clock(mut self, clock: impl FnMut() -> String + 'static) -> Self {
864        self.clock = Box::new(clock);
865        self
866    }
867
868    /// Called after every successful write (never after a dry run).
869    #[must_use]
870    pub fn with_mutation_hook(mut self, hook: impl FnMut() + 'static) -> Self {
871        self.on_mutation = Some(Box::new(hook));
872        self
873    }
874
875    /// The matcher thresholds the observe tools pass through.
876    #[must_use]
877    pub fn with_config(mut self, config: Config) -> Self {
878        self.config = config;
879        self
880    }
881
882    /// The serving binary's identity for the `version` tool (§4, 1.4): its
883    /// own version, `spec/cli` version, build commit and time, compiler. A
884    /// library embedding keeps the default (this crate's version, nulls).
885    #[must_use]
886    pub fn with_build_info(mut self, build_info: BuildInfo) -> Self {
887        self.build_info = build_info;
888        self
889    }
890
891    #[must_use]
892    pub fn store(&self) -> &Store {
893        &self.store
894    }
895
896    pub fn store_mut(&mut self) -> &mut Store {
897        &mut self.store
898    }
899
900    #[must_use]
901    pub fn default_repo(&self) -> &str {
902        &self.default_repo
903    }
904
905    /// The catalog.
906    #[must_use]
907    pub fn tools(&self) -> Vec<ToolSpec> {
908        tools()
909    }
910
911    /// Whether `name` is a tool that can commit (a host serializes these
912    /// under the writer lock of `spec/sync` §7; `plan` counts — it reads the
913    /// working tree the writes race for).
914    #[must_use]
915    pub fn is_write_tool(name: &str) -> bool {
916        matches!(
917            name,
918            "apply"
919                | "plan"
920                | "execute"
921                | "blocks_insert"
922                | "blocks_update"
923                | "blocks_move"
924                | "blocks_remove"
925                | "blocks_split"
926                | "blocks_merge"
927                | "blocks_check"
928                | "nodes_set"
929                | "blocks_append"
930                | "docs_append"
931                | "links_retarget"
932                | "links_repair"
933                | "docs_create"
934                | "docs_move"
935                | "docs_delete"
936                | "docs_set_meta"
937                | "docs_update"
938                | "observe"
939                | "observe_many"
940                | "observe_delete"
941        )
942    }
943
944    /// Run a tool: its JSON result, or the error envelope.
945    pub fn call(&mut self, name: &str, args: Json) -> ToolOutcome {
946        match self.call_result(name, &args) {
947            Ok(body) => ToolOutcome {
948                body,
949                is_error: false,
950            },
951            Err(e) => ToolOutcome {
952                body: e.to_json(),
953                is_error: true,
954            },
955        }
956    }
957
958    fn now(&mut self) -> String {
959        (self.clock)()
960    }
961
962    fn notify(&mut self) {
963        if let Some(hook) = &mut self.on_mutation {
964            hook();
965        }
966    }
967
968    // ---- repo scoping (§4) ------------------------------------------------------------
969
970    fn repo_rows(&self) -> Result<Vec<RepoRow>> {
971        Ok(omgbase_sync::workspace::list_repos(&self.store)?)
972    }
973
974    /// `(repo id, derived root)` for the `repo` argument; omitted → the default.
975    fn scope(&self, args: &Json) -> Result<(String, Option<String>)> {
976        let rows = self.repo_rows()?;
977        match arg_str(args, "repo") {
978            None => {
979                let root = rows
980                    .iter()
981                    .find(|r| r.repo_id == self.default_repo)
982                    .and_then(|r| r.root_path.clone());
983                Ok((self.default_repo.clone(), root))
984            }
985            Some(slug) => rows
986                .iter()
987                .find(|r| r.slug == slug)
988                .map(|r| (r.repo_id.clone(), r.root_path.clone()))
989                .ok_or_else(|| {
990                    SurfaceError::with_data(
991                        "repo_not_found",
992                        format!("no repo '{slug}' in this workspace"),
993                        json!({ "repo": slug }),
994                    )
995                }),
996        }
997    }
998
999    fn require_root(&self, root: Option<&str>) -> Result<()> {
1000        if matches!(self.writes, WriteTarget::Fixed(_)) || root.is_some() {
1001            Ok(())
1002        } else {
1003            Err(SurfaceError::new(
1004                "repo_not_found",
1005                "repo has no filesystem source; mutation disabled",
1006            ))
1007        }
1008    }
1009
1010    /// Run `f` with the store and the write target for `root`.
1011    fn with_writes<T>(
1012        &mut self,
1013        root: Option<&str>,
1014        f: impl FnOnce(&mut Store, &mut dyn DocStore) -> Result<T>,
1015    ) -> Result<T> {
1016        self.require_root(root)?;
1017        match &mut self.writes {
1018            WriteTarget::Fixed(ds) => f(&mut self.store, ds.as_mut()),
1019            WriteTarget::Derived => {
1020                let mut fs = FsDocStore::new(root.expect("checked by require_root"));
1021                f(&mut self.store, &mut fs)
1022            }
1023        }
1024    }
1025
1026    // ---- the document locator (§4 "Arguments") ---------------------------------------
1027    // `id` is the document's id, `path` its path (either form, §1 "Paths"); at
1028    // least one is required, and when both are given they must name the same
1029    // document. A tool whose prefix is not `docs` refers to a document as the
1030    // every `docs_*` tool — `docs_diff` and `docs_diff_unified` included — under the same
1031    // rules (the names are bare because the attributes themselves are becoming
1032    // bare — surface 3.0 attribute flip, follow-up). A miss is `doc_missing`
1033    // "no document with id or path "<value>"" with the arguments as given in
1034    // `data`; neither given is `filter_invalid` "pass id or path"; a
1035    // disagreement is `filter_invalid` "id "…" and path "…" name different
1036    // documents". The reference's `locateDoc`, byte for byte.
1037    /// A document locator (`id` or `path`, either form) resolved to the live
1038    /// document's id under the `docs_update` tool's rules — the error a
1039    /// `docs_update` with that locator would raise (`doc_missing` with the
1040    /// given keys as `data`). The CLI's local `update <doc>` resolves its
1041    /// target here before planning, as the reference's `requireDoc` does.
1042    pub fn locate_document(&self, repo_id: &str, args: &Json) -> Result<String> {
1043        self.locate_doc(repo_id, "docs_update", args)
1044    }
1045
1046    fn locate_doc(&self, repo_id: &str, tool: &str, args: &Json) -> Result<String> {
1047        let (id_key, path_key) = ("id", "path");
1048        let schema = &tool_spec(tool).expect("a catalog tool").input_schema;
1049        let id = arg_str(args, id_key);
1050        let path = arg_str(args, path_key);
1051        if id.is_none() && path.is_none() {
1052            return Err(argument_error(
1053                tool,
1054                &format!("pass {id_key} or {path_key}"),
1055                schema,
1056            ));
1057        }
1058        let mut given = Map::new();
1059        if let Some(v) = id {
1060            given.insert(id_key.to_owned(), json!(v));
1061        }
1062        if let Some(v) = path {
1063            given.insert(path_key.to_owned(), json!(v));
1064        }
1065        let missing = |value: &str| {
1066            SurfaceError::with_data(
1067                "doc_missing",
1068                format!(
1069                    "no document with {id_key} or {path_key} {}",
1070                    Json::String(value.to_owned())
1071                ),
1072                Json::Object(given.clone()),
1073            )
1074        };
1075        let conn = self.store.conn();
1076        let by_id = match id {
1077            Some(v) => {
1078                let info = read::find_doc_by_id(conn, v)?.filter(|i| i.repo_id == repo_id);
1079                Some(info.ok_or_else(|| missing(v))?.doc_id)
1080            }
1081            None => None,
1082        };
1083        let by_path = match path {
1084            Some(v) => Some(
1085                read::find_doc_by_path(conn, repo_id, v)?
1086                    .ok_or_else(|| missing(v))?
1087                    .doc_id,
1088            ),
1089            None => None,
1090        };
1091        if let (Some(a), Some(b)) = (&by_id, &by_path)
1092            && a != b
1093        {
1094            return Err(argument_error(
1095                tool,
1096                &format!(
1097                    "{id_key} {} and {path_key} {} name different documents",
1098                    Json::String(id.unwrap_or_default().to_owned()),
1099                    Json::String(path.unwrap_or_default().to_owned())
1100                ),
1101                schema,
1102            ));
1103        }
1104        Ok(by_id.or(by_path).expect("one locator given"))
1105    }
1106
1107    /// `semantic()` is loud when it cannot rank (§4, 3.0): a provider that
1108    /// answers but whose model has embedded NOTHING in this repository would
1109    /// score every row null and hand back path-ordered rows with no score, so
1110    /// a query naming a phrase (`semantic`) fails `provider_unavailable` up
1111    /// front, naming the provider and model and what to run. No provider at
1112    /// all is the arm's own `provider_unavailable` (checked before this).
1113    fn require_rankable(&self, repo_id: &str, semantic: bool) -> Result<()> {
1114        let Some(provider) = self.provider.as_deref().filter(|_| semantic) else {
1115            return Ok(());
1116        };
1117        let model = provider.model();
1118        let any: Option<i64> = self
1119            .store
1120            .conn()
1121            .query_row(
1122                "SELECT 1 WHERE EXISTS (SELECT 1 FROM doc_embeddings de JOIN docs d ON d.doc_id = de.doc_id WHERE d.repo_id = ?1 AND de.model = ?2) OR EXISTS (SELECT 1 FROM embeddings WHERE model = ?2)",
1123                params![repo_id, model],
1124                |r| r.get(0),
1125            )
1126            .optional()?;
1127        if any.is_some() {
1128            return Ok(());
1129        }
1130        let who = match &self.provider_name {
1131            Some(name) => format!("provider {} (model {model})", Json::String(name.clone())),
1132            None => format!("model {model}"),
1133        };
1134        Err(SurfaceError::with_data(
1135            "provider_unavailable",
1136            format!(
1137                "semantic(...) cannot rank: {who} is configured but nothing in this repository is embedded under it — run omg embed drain"
1138            ),
1139            json!({ "provider": self.provider_name, "model": model }),
1140        ))
1141    }
1142
1143    /// The owning document of a live block (`blocks_read`).
1144    fn doc_of_block(&self, block_id: &str) -> Result<String> {
1145        self.doc_id_of_block(block_id)?
1146            .ok_or_else(|| block_missing(block_id))
1147    }
1148
1149    /// `docs_append`'s `section`: a heading's TEXT, matched against the
1150    /// normalized heading text of the named document's live headings (an
1151    /// ATX-looking value, "## Title", loses its hashes). Zero →
1152    /// `parent_missing` naming the heading; several → `ambiguous_heading` with
1153    /// the candidates `[{ id, doc_id }]` (pass the heading's id to
1154    /// `blocks_append` instead). Never an id lookup — the id form is
1155    /// `blocks_append`'s (§9).
1156    fn resolve_section_heading(
1157        &self,
1158        doc_id: &str,
1159        doc_path: &str,
1160        section: &str,
1161    ) -> Result<String> {
1162        let conn = self.store.conn();
1163        let needle = if section.trim_start_matches([' ', '\t']).starts_with('#') {
1164            normalize_visible_text(section, BlockKind::Heading, 0)
1165        } else {
1166            normalize_text(section)
1167        };
1168        let mut stmt = conn.prepare(
1169            "SELECT block_id, doc_id, text FROM blocks WHERE doc_id = ?1 AND type = 'heading' AND deleted_commit IS NULL",
1170        )?;
1171        let rows: Vec<(String, String, String)> = stmt
1172            .query_map(params![doc_id], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?
1173            .collect::<std::result::Result<_, _>>()?;
1174        let matches: Vec<&(String, String, String)> = rows
1175            .iter()
1176            .filter(|(_, _, text)| normalize_text(text) == needle)
1177            .collect();
1178        let shown = reference_path(doc_path);
1179        match matches.len() {
1180            0 => Err(SurfaceError::with_data(
1181                "parent_missing",
1182                format!("no heading {} in {shown}", Json::String(section.to_owned())),
1183                json!({ "section": section, "path": shown }),
1184            )),
1185            1 => Ok(matches[0].0.clone()),
1186            n => Err(SurfaceError::with_data(
1187                "ambiguous_heading",
1188                format!(
1189                    "heading {} matches {n} headings in {shown}; pass the heading's id to blocks_append",
1190                    Json::String(section.to_owned())
1191                ),
1192                json!({
1193                    "section": section,
1194                    "path": shown,
1195                    "candidates": matches.iter().map(|(b, d, _)| json!({ "id": b, "doc_id": d })).collect::<Vec<_>>(),
1196                }),
1197            )),
1198        }
1199    }
1200
1201    /// `nodes_block_read`: the `(doc_id, block_id)` of the live block a node
1202    /// was projected from — `block_missing` for an unknown node ("no node with
1203    /// id …") or a node with no live block ("node … has no block").
1204    fn block_of_node(&self, node_id: &str) -> Result<(String, String)> {
1205        let node: Option<(String, Option<String>)> = self
1206            .store
1207            .conn()
1208            .query_row(
1209                "SELECT doc_id, block_id FROM nodes WHERE node_id = ?1",
1210                params![node_id],
1211                |r| Ok((r.get(0)?, r.get(1)?)),
1212            )
1213            .optional()?;
1214        let Some((doc_id, block_id)) = node else {
1215            return Err(SurfaceError::with_data(
1216                "block_missing",
1217                format!("no node with id {}", Json::String(node_id.to_owned())),
1218                json!({ "id": node_id }),
1219            ));
1220        };
1221        let live = match &block_id {
1222            Some(b) => self.doc_id_of_block(b)?.is_some(),
1223            None => false,
1224        };
1225        match block_id {
1226            Some(b) if live => Ok((doc_id, b)),
1227            _ => Err(node_has_no_block(node_id, &doc_id)),
1228        }
1229    }
1230
1231    /// A ref → a live block id (`block_missing` otherwise).
1232    fn resolve_block_ref(&self, repo_id: &str, r: &str) -> Result<String> {
1233        match read::resolve_ref(self.store.conn(), repo_id, r)? {
1234            Some(ResolvedRef::Block { block_id, .. }) => Ok(block_id),
1235            _ => Err(block_missing(r)),
1236        }
1237    }
1238
1239    /// A parent ref: a block, or a document ref for its top level.
1240    fn resolve_parent_ref(&self, repo_id: &str, r: &str) -> Result<(Parent, String)> {
1241        match read::resolve_ref(self.store.conn(), repo_id, r)? {
1242            Some(ResolvedRef::Block { doc_id, block_id }) => Ok((Parent::Block(block_id), doc_id)),
1243            Some(ResolvedRef::Document { doc_id }) => Ok((Parent::Doc, doc_id)),
1244            None => Err(SurfaceError::with_data(
1245                "block_missing",
1246                format!(
1247                    "no block or document with id or path {}",
1248                    Json::String(r.to_owned())
1249                ),
1250                json!({ "ref": r }),
1251            )),
1252        }
1253    }
1254
1255    fn doc_id_of_block(&self, block_id: &str) -> Result<Option<String>> {
1256        Ok(self
1257            .store
1258            .conn()
1259            .query_row(
1260                "SELECT doc_id FROM blocks WHERE block_id = ?1 AND deleted_commit IS NULL",
1261                params![block_id],
1262                |r| r.get(0),
1263            )
1264            .optional()?)
1265    }
1266
1267    /// The `at` spec with its anchor ref resolved; absent → end.
1268    fn resolve_at(&self, repo_id: &str, at: Option<&Json>) -> Result<At> {
1269        match at {
1270            None | Some(Json::Null) => Ok(At::End),
1271            Some(Json::String(s)) if s == "start" => Ok(At::Start),
1272            Some(Json::String(s)) if s == "end" => Ok(At::End),
1273            Some(Json::Object(o)) => {
1274                if let Some(b) = o.get("before").and_then(Json::as_str) {
1275                    return Ok(At::Before(self.resolve_block_ref(repo_id, b)?));
1276                }
1277                if let Some(a) = o.get("after").and_then(Json::as_str) {
1278                    return Ok(At::After(self.resolve_block_ref(repo_id, a)?));
1279                }
1280                Err(bad_args(
1281                    "`at` must be \"start\", \"end\", {before} or {after}",
1282                ))
1283            }
1284            Some(_) => Err(bad_args(
1285                "`at` must be \"start\", \"end\", {before} or {after}",
1286            )),
1287        }
1288    }
1289
1290    // ---- the apply tail -----------------------------------------------------------------
1291
1292    /// A sugar tool's effects through the planner (ADR-021): `plan` +
1293    /// `execute` in one call under one write scope and one transaction,
1294    /// `reason` the tool's name — the Receipt (§4, 4.0); previewing
1295    /// ([`Surface::preview_call`]) the Plan alone, nothing committed. The
1296    /// mutation hook fires on a commit.
1297    fn apply_sugar(
1298        &mut self,
1299        repo_id: &str,
1300        root: Option<&str>,
1301        effects: Vec<PublicEffect>,
1302        reason: &str,
1303    ) -> Result<Json> {
1304        let out = self.apply_sugar_with(repo_id, root, reason, |_, _| Ok(effects))?;
1305        Ok(sugar_json(&out))
1306    }
1307
1308    /// [`Surface::apply_sugar`]'s seam for the tools whose result carries
1309    /// more than the Receipt (`docs_move`, `links_*`): `build` runs under the
1310    /// write scope with the store and the doc store — the tool's own
1311    /// pre-checks, then its effects — and the plan and the receipt (`None`
1312    /// when previewing) come back for the tool to shape. The plan is kept for
1313    /// [`Surface::call_result_with_plan`].
1314    fn apply_sugar_with(
1315        &mut self,
1316        repo_id: &str,
1317        root: Option<&str>,
1318        reason: &str,
1319        build: impl FnOnce(&mut Store, &mut dyn DocStore) -> Result<Vec<PublicEffect>>,
1320    ) -> Result<SugarOutcome> {
1321        let origin = ApplyOrigin::new(&self.actor, Some(reason));
1322        let ctx = self.plan_ctx(repo_id);
1323        let preview = self.preview;
1324        let out = self.with_writes(root, |store, ds| {
1325            let effects = build(store, ds)?;
1326            if preview {
1327                let plan = store
1328                    .plan_effects(&ctx, &effects, ds)
1329                    .map_err(strip_effect_index)?;
1330                return Ok((plan, None));
1331            }
1332            let (plan, receipt) = store
1333                .apply_effects_detailed(&ctx, &effects, &origin, ds)
1334                .map_err(strip_effect_index)?;
1335            Ok((plan, Some(receipt)))
1336        })?;
1337        self.last_plan = Some(out.0.clone());
1338        if out.1.is_some() {
1339            self.notify();
1340        }
1341        Ok(out)
1342    }
1343
1344    /// A located document's row (`locate_doc` has already resolved it, so a
1345    /// miss here is a race) as `{ doc_id, path, current_rev }`.
1346    fn doc_info(&self, repo_id: &str, doc_ref: &str) -> Result<omgbase_store::DocInfo> {
1347        self.store
1348            .find_doc_by_ref(repo_id, doc_ref)?
1349            .ok_or_else(|| {
1350                SurfaceError::from(MutationError::new(
1351                    ErrorCode::DocMissing,
1352                    format!("no document {doc_ref}"),
1353                ))
1354            })
1355    }
1356
1357    /// Run a write tool and hand back its result **with the Plan it
1358    /// committed** (`spec/cli` §4, the Receipt confirmation): a sugar tool's
1359    /// or `apply`'s plan, whose `preview.results[].ids` realized through the
1360    /// receipt's `new_ids` are the ids the CLI prints; `None` for a tool that
1361    /// planned nothing (a read, `execute`, an error). Not a tool: the CLI's
1362    /// local seam, as the reference's library-bound verbs hold the plan from
1363    /// `applyEffectsDetailed`.
1364    pub fn call_result_with_plan(
1365        &mut self,
1366        name: &str,
1367        args: &Json,
1368    ) -> Result<(Json, Option<Json>)> {
1369        self.last_plan = None;
1370        let result = self.call_result(name, args);
1371        let plan = self.last_plan.take().map(|p| plan_json(&p));
1372        Ok((result?, plan))
1373    }
1374
1375    /// The Plan a sugar tool would commit (`spec/cli` §3.6: every dry run is
1376    /// a `plan`) — the tool's own ref resolution and pre-checks run, its
1377    /// effects are planned under the symbolic minter, nothing is written and
1378    /// no id is minted; the mutation hook stays quiet. `apply` previews its
1379    /// effects the same way. Not a tool: the catalog has no `dry_run` (4.0),
1380    /// a client previews with `plan { effects }`.
1381    pub fn preview_call(&mut self, name: &str, args: &Json) -> Result<Json> {
1382        self.preview = true;
1383        let out = self.call_result(name, args);
1384        self.preview = false;
1385        out
1386    }
1387
1388    /// The `apply` tool's effects: `(plan, receipt)` on the wire — plan +
1389    /// execute in one write scope and one transaction; previewing, the plan
1390    /// and nothing else.
1391    fn apply_effects_detailed(
1392        &mut self,
1393        repo: &str,
1394        root: Option<&str>,
1395        args: &Json,
1396    ) -> Result<(Json, Option<Json>)> {
1397        let effects = parse_effects(&args["effects"])?;
1398        let reason = arg_str(args, "reason").map(str::to_owned);
1399        let origin = ApplyOrigin::new(&self.actor, reason.as_deref());
1400        let ctx = self.plan_ctx(repo);
1401        let preview = self.preview;
1402        let (plan, receipt) = self.with_writes(root, |store, ds| {
1403            if preview {
1404                return Ok((store.plan_effects(&ctx, &effects, ds)?, None));
1405            }
1406            let (plan, receipt) = store.apply_effects_detailed(&ctx, &effects, &origin, ds)?;
1407            Ok((plan, Some(receipt)))
1408        })?;
1409        self.last_plan = Some(plan.clone());
1410        if receipt.is_some() {
1411            self.notify();
1412        }
1413        Ok((plan_json(&plan), receipt.as_ref().map(receipt_json)))
1414    }
1415
1416    fn plan_ctx(&mut self, repo_id: &str) -> PlanContext {
1417        PlanContext {
1418            repo_id: repo_id.to_owned(),
1419            ts: self.now(),
1420            config: self.config.clone(),
1421        }
1422    }
1423
1424    // ---- dispatch -------------------------------------------------------------------------
1425
1426    /// Run a tool, returning its result or the error.
1427    #[allow(clippy::too_many_lines)]
1428    pub fn call_result(&mut self, name: &str, args: &Json) -> Result<Json> {
1429        // §4 "Arguments": the shape first, so a wrong name is one
1430        // `filter_invalid` naming the nearest valid one.
1431        let spec =
1432            tool_spec(name).ok_or_else(|| SurfaceError::other(format!("unknown tool {name}")))?;
1433        validate_arguments(name, &spec.input_schema, args)?;
1434        match name {
1435            "docs_outline" => {
1436                let (repo, _) = self.scope(args)?;
1437                let doc_id = self.locate_doc(&repo, name, args)?;
1438                let skeleton = match arg_str(args, "resolution") {
1439                    None | Some("outline") => false,
1440                    Some("skeleton") => true,
1441                    Some(_) => return Err(bad_args("`resolution` must be skeleton or outline")),
1442                };
1443                read::docs_outline(
1444                    &self.store,
1445                    &doc_id,
1446                    skeleton,
1447                    arg_i64(args, "depth")?,
1448                    arg_usize(args, "budget_tokens")?,
1449                )
1450            }
1451            "docs_read" => {
1452                let (repo, _) = self.scope(args)?;
1453                let doc_id = self.locate_doc(&repo, name, args)?;
1454                read::docs_read(
1455                    &self.store,
1456                    &doc_id,
1457                    arg_bool(args, "include_ids")?.unwrap_or(false),
1458                )?
1459                .ok_or_else(|| {
1460                    SurfaceError::with_data(
1461                        "doc_missing",
1462                        format!(
1463                            "no document with id or path {}",
1464                            Json::String(
1465                                arg_str(args, "id")
1466                                    .or(arg_str(args, "path"))
1467                                    .unwrap_or_default()
1468                                    .to_owned()
1469                            )
1470                        ),
1471                        json!({ "id": doc_id }),
1472                    )
1473                })
1474            }
1475            "docs_read_many" => {
1476                let (repo, _) = self.scope(args)?;
1477                let refs = arg_strings(args, "docs")?;
1478                read::docs_read_many(
1479                    &self.store,
1480                    &repo,
1481                    &refs,
1482                    arg_bool(args, "include_ids")?.unwrap_or(false),
1483                    arg_usize(args, "budget_tokens")?,
1484                )
1485            }
1486            "blocks_read" => {
1487                self.scope(args)?;
1488                let id = arg_string(args, "id")?;
1489                // `id` is a block id, unambiguously the `blocks_` kind's (§4
1490                // "Names and arguments"): an `n_` id names a NODE and is
1491                // `block_missing` pointing at `nodes_block_read` (the name pass
1492                // had dereferenced it here, §9).
1493                if id.starts_with("n_") {
1494                    return Err(SurfaceError::with_data(
1495                        "block_missing",
1496                        format!(
1497                            "no block with id {} (an n_ id names a node — nodes_block_read reads its block)",
1498                            Json::String(id.clone())
1499                        ),
1500                        json!({ "id": id }),
1501                    ));
1502                }
1503                let doc_id = self.doc_of_block(&id)?;
1504                read::blocks_read(
1505                    &self.store,
1506                    &doc_id,
1507                    &id,
1508                    resolution_arg(args, Resolution::Full)?,
1509                )?
1510                .ok_or_else(|| block_missing(&id))
1511            }
1512            "nodes_block_read" => {
1513                self.scope(args)?;
1514                let id = arg_string(args, "id")?;
1515                // The block a node was projected from: the node must exist and
1516                // name a live block (a block-less node — one derived from the
1517                // frontmatter — has none). Both misses are `block_missing`,
1518                // the message naming the node.
1519                let (doc_id, block_id) = self.block_of_node(&id)?;
1520                read::blocks_read(
1521                    &self.store,
1522                    &doc_id,
1523                    &block_id,
1524                    resolution_arg(args, Resolution::Full)?,
1525                )?
1526                .ok_or_else(|| node_has_no_block(&id, &doc_id))
1527            }
1528            "blocks_read_many" => {
1529                self.scope(args)?;
1530                let ids = arg_strings(args, "ids")?;
1531                read::blocks_read_many(
1532                    &self.store,
1533                    None,
1534                    &ids,
1535                    resolution_arg(args, Resolution::Text)?,
1536                    arg_usize(args, "budget_tokens")?,
1537                )
1538            }
1539            "docs_tree" => {
1540                let (repo, _) = self.scope(args)?;
1541                read::docs_tree(
1542                    &self.store,
1543                    &repo,
1544                    arg_str(args, "path"),
1545                    arg_i64(args, "depth")?,
1546                    arg_i64(args, "limit")?,
1547                    arg_str(args, "cursor"),
1548                    arg_usize(args, "budget_tokens")?,
1549                )
1550            }
1551            "docs_list" => {
1552                let (repo, _) = self.scope(args)?;
1553                read::docs_list(
1554                    &self.store,
1555                    &repo,
1556                    arg_str(args, "path_glob"),
1557                    arg_i64(args, "limit")?,
1558                    arg_str(args, "cursor"),
1559                    arg_usize(args, "budget_tokens")?,
1560                )
1561            }
1562            "query_syntax" => Ok(json!({ "syntax": QUERY_SYNTAX })),
1563            "query" => {
1564                let (repo, _) = self.scope(args)?;
1565                let source = arg_string(args, "query")?;
1566                // A `semantic(...)` query with no provider is `provider_unavailable`
1567                // here (the runner itself reports `filter_invalid`, §9).
1568                if self.provider.is_none()
1569                    && !crate::query::collect_semantic_phrases(&source).is_empty()
1570                {
1571                    return Err(provider_unavailable());
1572                }
1573                let opts = QueryOptions {
1574                    limit: arg_usize(args, "limit")?,
1575                    cursor: arg_str(args, "cursor"),
1576                    provider: self.provider.as_deref(),
1577                    in_memory: false,
1578                };
1579                self.require_rankable(
1580                    &repo,
1581                    !crate::query::collect_semantic_phrases(&source).is_empty(),
1582                )?;
1583                Ok(query(&self.store, &repo, &source, opts)?.to_json())
1584            }
1585            "graph" => {
1586                let (repo, _) = self.scope(args)?;
1587                let g = GraphArgs {
1588                    roots: arg_strings(args, "roots")?,
1589                    degrees: arg_i64(args, "degrees")?,
1590                    direction: arg_str(args, "direction").map(str::to_owned),
1591                    predicate: arg_str(args, "predicate").map(str::to_owned),
1592                    select: if args.get("select").is_some() {
1593                        arg_strings(args, "select")?
1594                    } else {
1595                        Vec::new()
1596                    },
1597                    max_documents: arg_i64(args, "max_documents")?,
1598                };
1599                if self.provider.is_none()
1600                    && g.select.iter().any(|s| {
1601                        !crate::query::collect_semantic_phrases(&format!("from docs select x: {s}"))
1602                            .is_empty()
1603                    })
1604                {
1605                    return Err(provider_unavailable());
1606                }
1607                self.require_rankable(
1608                    &repo,
1609                    g.select.iter().any(|s| {
1610                        !crate::query::collect_semantic_phrases(&format!("from docs select x: {s}"))
1611                            .is_empty()
1612                    }),
1613                )?;
1614                graph_neighborhood(&self.store, &repo, &g, self.provider.as_deref())
1615            }
1616            "blocks_search" => {
1617                let (repo, _) = self.scope(args)?;
1618                let q = arg_string(args, "q")?;
1619                let res =
1620                    self.store
1621                        .text_search(&repo, &q, arg_usize(args, "limit")?.unwrap_or(50))?;
1622                Ok(json!({
1623                    "hits": res.hits.iter().map(|h| json!({
1624                        "id": h.block_id, "doc_id": h.doc_id, "path": reference_path(&h.path), "type": h.block_type, "text": h.text, "score": h.score,
1625                    })).collect::<Vec<_>>(),
1626                    "truncated": res.truncated,
1627                }))
1628            }
1629            "blocks_resolve" => {
1630                let (repo, _) = self.scope(args)?;
1631                let q = arg_string(args, "query")?;
1632                let vector = match &self.provider {
1633                    Some(p) => Some(QueryVector {
1634                        model: p.model().to_owned(),
1635                        vec: p
1636                            .embed_query(&q)
1637                            .map_err(|e| SurfaceError::new(e.code(), e.to_string()))?,
1638                    }),
1639                    None => None,
1640                };
1641                let hits = self
1642                    .store
1643                    .resolve(&repo, &q, vector, arg_usize(args, "limit")?)?;
1644                // The locator is `<path>#<type>[<ordinal>]`: its path half is rooted
1645                // (one without `#` names no path and is left alone).
1646                let locator = |l: &str| match l.find('#') {
1647                    Some(i) => format!("{}{}", reference_path(&l[..i]), &l[i..]),
1648                    None => l.to_owned(),
1649                };
1650                Ok(Json::Array(hits.iter().map(|h| json!({
1651                    "id": h.id, "locator": locator(&h.locator), "preview": h.preview, "evidence": evidence_json(&h.evidence),
1652                })).collect()))
1653            }
1654            "apply" => {
1655                let (repo, root) = self.scope(args)?;
1656                self.require_root(root.as_deref())?;
1657                // §4 (4.0): `effects` only — `ops` and `dry_run` are retired
1658                // names the shape check above refused with their replacements.
1659                let (plan, receipt) = self.apply_effects_detailed(&repo, root.as_deref(), args)?;
1660                Ok(receipt.unwrap_or(plan))
1661            }
1662            "plan" => {
1663                let (repo, root) = self.scope(args)?;
1664                self.require_root(root.as_deref())?;
1665                let effects = parse_effects(&args["effects"])?;
1666                let ctx = self.plan_ctx(&repo);
1667                let plan = self.with_writes(root.as_deref(), |store, ds| {
1668                    Ok(store.plan_effects(&ctx, &effects, ds)?)
1669                })?;
1670                Ok(plan_json(&plan))
1671            }
1672            "execute" => {
1673                let (repo, root) = self.scope(args)?;
1674                self.require_root(root.as_deref())?;
1675                let reason = arg_str(args, "reason").map(str::to_owned);
1676                let origin = ApplyOrigin::new(&self.actor, reason.as_deref());
1677                let ctx = self.plan_ctx(&repo);
1678                // §4 (3.1): exactly one form — the plan, or `plan_id` + `effects`.
1679                let plan_form = args.get("plan").is_some();
1680                let digest_form = args.get("plan_id").is_some() || args.get("effects").is_some();
1681                if plan_form == digest_form {
1682                    return Err(argument_error(
1683                        name,
1684                        "pass exactly one of `plan` or `plan_id` + `effects`",
1685                        &spec.input_schema,
1686                    ));
1687                }
1688                let receipt = if plan_form {
1689                    let plan = match Plan::admit(&args["plan"]) {
1690                        Ok(plan) => plan,
1691                        Err(Admission::Refused(m)) => return Err(SurfaceError::from(m)),
1692                        Err(Admission::Invalid(m)) => {
1693                            return Err(SurfaceError::filter_invalid(m, "arguments"));
1694                        }
1695                    };
1696                    self.with_writes(root.as_deref(), |store, ds| {
1697                        Ok(store.execute_plan(&ctx, &plan, &origin, ds)?)
1698                    })?
1699                } else {
1700                    let (Some(plan_id), Some(effects)) =
1701                        (arg_str(args, "plan_id"), args.get("effects"))
1702                    else {
1703                        return Err(argument_error(
1704                            name,
1705                            "the digest form needs both `plan_id` and `effects`",
1706                            &spec.input_schema,
1707                        ));
1708                    };
1709                    let effects = parse_effects(effects)?;
1710                    let plan_id = plan_id.to_owned();
1711                    self.with_writes(root.as_deref(), |store, ds| {
1712                        Ok(store.execute_by_digest(&ctx, &plan_id, &effects, &origin, ds)?)
1713                    })?
1714                };
1715                self.notify();
1716                Ok(receipt_json(&receipt))
1717            }
1718            // ---- the sugar tools (ADR-021 migration: `apply { effects }` sugar) ----
1719            "blocks_insert" => {
1720                let (repo, root) = self.scope(args)?;
1721                self.require_root(root.as_deref())?;
1722                let (parent, doc_id) = self.resolve_parent_ref(&repo, &arg_string(args, "to")?)?;
1723                let at = self.resolve_at(&repo, args.get("at"))?;
1724                let effects = sugar::insert(
1725                    placement_target(parent, doc_id),
1726                    at,
1727                    &arg_string(args, "markdown")?,
1728                    effect_expect(arg_parent_expect(args)?),
1729                );
1730                self.apply_sugar(&repo, root.as_deref(), effects, "blocks_insert")
1731            }
1732            "blocks_update" => {
1733                let (repo, root) = self.scope(args)?;
1734                self.require_root(root.as_deref())?;
1735                let block = self.resolve_block_ref(&repo, &arg_string(args, "id")?)?;
1736                let markdown = arg_str(args, "markdown");
1737                let checked = arg_bool(args, "checked")?;
1738                // `set markdown` then `set attrs.checked` (4.0: `checked` is
1739                // the one editable attribute — `attrs` is a retired name);
1740                // the caller's `expect` rides the first, a missing
1741                // `content_hash` is pinned from the live block (3.2).
1742                let effects = sugar::update_block(
1743                    &block,
1744                    markdown,
1745                    checked,
1746                    effect_expect(arg_expect(args)?),
1747                );
1748                if effects.is_empty() {
1749                    return Err(argument_error(
1750                        name,
1751                        "pass `markdown` and/or `checked`",
1752                        &spec.input_schema,
1753                    ));
1754                }
1755                self.apply_sugar(&repo, root.as_deref(), effects, "blocks_update")
1756            }
1757            "blocks_move" => {
1758                let (repo, root) = self.scope(args)?;
1759                self.require_root(root.as_deref())?;
1760                let blocks: Vec<String> =
1761                    require_some(name, "blocks", arg_strings(args, "blocks")?, spec)?
1762                        .iter()
1763                        .map(|b| self.resolve_block_ref(&repo, b))
1764                        .collect::<Result<_>>()?;
1765                let to_ref = arg_string(args, "to")?;
1766                let (parent, doc_id) = self.resolve_parent_ref(&repo, &to_ref)?;
1767                if parent == Parent::Doc {
1768                    if let Some(first) = blocks.first() {
1769                        if self.doc_id_of_block(first)? != Some(doc_id.clone()) {
1770                            return Err(SurfaceError::with_data(
1771                                "target_missing",
1772                                format!(
1773                                    "blocks_move cannot target another document's root ({to_ref}); anchor on a block in that document with at.before/at.after"
1774                                ),
1775                                json!({ "to": to_ref }),
1776                            ));
1777                        }
1778                    }
1779                }
1780                let at = self.resolve_at(&repo, args.get("at"))?;
1781                let effects = sugar::move_blocks(
1782                    blocks,
1783                    placement_target(parent, doc_id),
1784                    at,
1785                    effect_expect(arg_parent_expect(args)?),
1786                );
1787                self.apply_sugar(&repo, root.as_deref(), effects, "blocks_move")
1788            }
1789            "blocks_remove" => {
1790                let (repo, root) = self.scope(args)?;
1791                self.require_root(root.as_deref())?;
1792                let blocks: Vec<String> =
1793                    require_some(name, "blocks", arg_strings(args, "blocks")?, spec)?
1794                        .iter()
1795                        .map(|b| self.resolve_block_ref(&repo, b))
1796                        .collect::<Result<_>>()?;
1797                let effects = sugar::remove_blocks(blocks);
1798                self.apply_sugar(&repo, root.as_deref(), effects, "blocks_remove")
1799            }
1800            "blocks_split" => {
1801                let (repo, root) = self.scope(args)?;
1802                self.require_root(root.as_deref())?;
1803                let block = self.resolve_block_ref(&repo, &arg_string(args, "id")?)?;
1804                let at: Vec<usize> = args
1805                    .get("at")
1806                    .and_then(Json::as_array)
1807                    .ok_or_else(|| bad_args("`at` must be an array of byte offsets"))?
1808                    .iter()
1809                    .map(|v| {
1810                        v.as_u64()
1811                            .map(|n| usize::try_from(n).unwrap_or(usize::MAX))
1812                            .ok_or_else(|| bad_args("`at` must be an array of byte offsets"))
1813                    })
1814                    .collect::<Result<_>>()?;
1815                let effects = sugar::split_block(&block, at);
1816                self.apply_sugar(&repo, root.as_deref(), effects, "blocks_split")
1817            }
1818            "blocks_merge" => {
1819                let (repo, root) = self.scope(args)?;
1820                self.require_root(root.as_deref())?;
1821                let blocks: Vec<String> =
1822                    require_some(name, "blocks", arg_strings(args, "blocks")?, spec)?
1823                        .iter()
1824                        .map(|b| self.resolve_block_ref(&repo, b))
1825                        .collect::<Result<_>>()?;
1826                let effects = sugar::merge_blocks(blocks, arg_str(args, "separator"));
1827                self.apply_sugar(&repo, root.as_deref(), effects, "blocks_merge")
1828            }
1829            "blocks_check" => {
1830                let (repo, root) = self.scope(args)?;
1831                self.require_root(root.as_deref())?;
1832                let blocks: Vec<String> =
1833                    require_some(name, "ids", arg_strings(args, "ids")?, spec)?
1834                        .iter()
1835                        .map(|b| self.resolve_block_ref(&repo, b))
1836                        .collect::<Result<_>>()?;
1837                // N × `set attrs.checked`, each pinned by the planner; an id
1838                // named twice is one identical scalar write — the planner
1839                // drops the duplicate (a pinned 3.2 fix: the second op no
1840                // longer fails `stale_expectation`).
1841                let effects =
1842                    sugar::check_blocks(&blocks, arg_bool(args, "checked")?.unwrap_or(true));
1843                self.apply_sugar(&repo, root.as_deref(), effects, "blocks_check")
1844            }
1845            "nodes_set" => {
1846                let (repo, root) = self.scope(args)?;
1847                self.require_root(root.as_deref())?;
1848                let node_id = arg_string(args, "id")?;
1849                let known: Option<i64> = self
1850                    .store
1851                    .conn()
1852                    .query_row(
1853                        "SELECT 1 FROM nodes WHERE node_id = ?1 AND repo_id = ?2",
1854                        params![node_id, repo],
1855                        |r| r.get(0),
1856                    )
1857                    .optional()?;
1858                if known.is_none() {
1859                    return Err(SurfaceError::with_data(
1860                        "block_missing",
1861                        format!("no node with id {}", Json::String(node_id.clone())),
1862                        json!({ "id": node_id }),
1863                    ));
1864                }
1865                let effects = sugar::set_node(
1866                    &node_id,
1867                    &arg_string(args, "prop")?,
1868                    &arg_string(args, "value")?,
1869                );
1870                self.apply_sugar(&repo, root.as_deref(), effects, "nodes_set")
1871            }
1872            "blocks_append" => {
1873                let (repo, root) = self.scope(args)?;
1874                self.require_root(root.as_deref())?;
1875                let given = arg_string(args, "id")?;
1876                let block = self.resolve_block_ref(&repo, &given)?;
1877                let text = arg_string(args, "text")?;
1878                // By what the block is (the reference's `blocks_append`): a heading
1879                // → the end of its section; a container → the end of its children;
1880                // a leaf → `block_not_container` (append means into).
1881                let row: Option<(String, Option<String>, i64)> = self
1882                    .store
1883                    .conn()
1884                    .query_row(
1885                        "SELECT b.type, d.format, (SELECT count(*) FROM blocks c WHERE c.parent_block = b.block_id AND c.deleted_commit IS NULL) FROM blocks b JOIN docs d ON d.doc_id = b.doc_id WHERE b.block_id = ?1 AND b.deleted_commit IS NULL",
1886                        params![block],
1887                        |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
1888                    )
1889                    .optional()?;
1890                let Some((kind, format, children)) = row else {
1891                    return Err(block_missing(&given));
1892                };
1893                let container = if format.as_deref().unwrap_or("markdown") == "markdown" {
1894                    kind.parse::<BlockKind>().is_ok_and(|k| k.is_container())
1895                } else {
1896                    children > 0
1897                };
1898                let into = if kind == "heading" {
1899                    EffectTarget::Section { heading_id: block }
1900                } else if container {
1901                    EffectTarget::Block { id: block }
1902                } else {
1903                    return Err(SurfaceError::with_data(
1904                        "block_not_container",
1905                        format!(
1906                            "block {block} is a {kind}, not a heading or a container; append means into — insert after it with blocks_insert {{ to: <its parent>, at: {{ after: {} }} }}",
1907                            Json::String(block.clone())
1908                        ),
1909                        json!({ "id": block, "type": kind }),
1910                    ));
1911                };
1912                let effects = sugar::insert(into, At::End, &text, None);
1913                self.apply_sugar(&repo, root.as_deref(), effects, "blocks_append")
1914            }
1915            "docs_append" => {
1916                let (repo, root) = self.scope(args)?;
1917                self.require_root(root.as_deref())?;
1918                let doc_id = self.locate_doc(&repo, name, args)?;
1919                let text = arg_string(args, "text")?;
1920                // The heading-TEXT resolution (and `ambiguous_heading`) stays
1921                // the tool's; the effect names the heading by id.
1922                let into = match arg_str(args, "section") {
1923                    Some(section) => {
1924                        let info = read::find_doc_by_id(self.store.conn(), &doc_id)?
1925                            .ok_or_else(|| block_missing(&doc_id))?;
1926                        let heading = self.resolve_section_heading(&doc_id, &info.path, section)?;
1927                        EffectTarget::Section {
1928                            heading_id: heading,
1929                        }
1930                    }
1931                    None => EffectTarget::Document {
1932                        id: Some(doc_id),
1933                        path: None,
1934                    },
1935                };
1936                let effects = sugar::insert(into, At::End, &text, None);
1937                self.apply_sugar(&repo, root.as_deref(), effects, "docs_append")
1938            }
1939            "links_retarget" | "links_repair" => {
1940                let (repo, root) = self.scope(args)?;
1941                self.require_root(root.as_deref())?;
1942                let repairs: Vec<omgbase_store::LinkRepair> = if name == "links_retarget" {
1943                    vec![omgbase_store::LinkRepair {
1944                        from: arg_string(args, "from_target")?,
1945                        to: arg_string(args, "to_target")?,
1946                    }]
1947                } else if let Some(list) = args.get("repairs").and_then(Json::as_array) {
1948                    list.iter()
1949                        .map(|r| {
1950                            Ok(omgbase_store::LinkRepair {
1951                                from: arg_string(r, "from")?,
1952                                to: arg_string(r, "to")?,
1953                            })
1954                        })
1955                        .collect::<Result<_>>()?
1956                } else if let (Some(f), Some(t)) =
1957                    (arg_str(args, "from_target"), arg_str(args, "to_target"))
1958                {
1959                    vec![omgbase_store::LinkRepair {
1960                        from: f.to_owned(),
1961                        to: t.to_owned(),
1962                    }]
1963                } else {
1964                    Vec::new()
1965                };
1966                if repairs.is_empty() {
1967                    return Err(SurfaceError::new(
1968                        "target_missing",
1969                        "links_repair requires `repairs` (array of {from,to}) or a `from_target`+`to_target` pair",
1970                    ));
1971                }
1972                // `hits` / `pairs` come from the store's plan as before; the
1973                // `retarget` effect re-plans the same rewrite for the commit
1974                // (4.0: committed like every other tool, no `applied`; the
1975                // preview is `plan { retarget }` → `preview.retarget`).
1976                let path_glob = arg_str(args, "path_glob").filter(|g| !g.is_empty());
1977                let plan = self.store.links_repair(&repo, &repairs, path_glob)?;
1978                let pairs: Vec<(String, String)> = repairs
1979                    .iter()
1980                    .map(|r| (r.from.clone(), r.to.clone()))
1981                    .collect();
1982                let effects = sugar::retarget(&pairs, path_glob);
1983                let out =
1984                    self.apply_sugar_with(&repo, root.as_deref(), name, |_, _| Ok(effects))?;
1985                let mut m = Map::new();
1986                m.insert(
1987                    "hits".to_owned(),
1988                    Json::Array(plan.hits.iter().map(|h| json!({ "id": h.block, "path": reference_path(&h.path), "oldRaw": h.old_raw, "newRaw": h.new_raw })).collect()),
1989                );
1990                m.insert(
1991                    "pairs".to_owned(),
1992                    Json::Array(
1993                        plan.pairs
1994                            .iter()
1995                            .map(|p| json!({ "from": p.from, "to": p.to, "hits": p.hits }))
1996                            .collect(),
1997                    ),
1998                );
1999                Ok(merge(m, sugar_json(&out)))
2000            }
2001            "links_stale" => {
2002                let (repo, _) = self.scope(args)?;
2003                let glob = arg_str(args, "path_glob").filter(|g| !g.is_empty());
2004                if arg_bool(args, "summary")?.unwrap_or(false) {
2005                    return links::links_stale_summary(&self.store, &repo, glob);
2006                }
2007                links::links_stale(&self.store, &repo, glob, arg_i64(args, "limit")?)
2008            }
2009            "docs_create" => {
2010                let (repo, root) = self.scope(args)?;
2011                let fm = arg_object(args, "frontmatter")?.cloned();
2012                let sugar::Sugar { effects, reason } = sugar::create_document(
2013                    &arg_string(args, "path")?,
2014                    &arg_string(args, "markdown")?,
2015                    fm.as_ref(),
2016                );
2017                let rel = canonical(&arg_string(args, "path")?);
2018                // The library's own checks first, in its order and with its
2019                // messages (`path_taken` with an empty `data`, as pinned); the
2020                // planner's `check_absent` would name the path in `data`.
2021                let out = self.apply_sugar_with(&repo, root.as_deref(), &reason, |store, ds| {
2022                    if store.live_doc_at(&repo, &rel)? {
2023                        return Err(path_taken(format!("document already exists at {rel}")));
2024                    }
2025                    if ds.exists(&rel) {
2026                        return Err(path_taken(format!("file already exists on disk at {rel}")));
2027                    }
2028                    Ok(effects)
2029                })?;
2030                Ok(sugar_json(&out))
2031            }
2032            "docs_move" => {
2033                let (repo, root) = self.scope(args)?;
2034                let doc = self.locate_doc(&repo, name, args)?;
2035                let to_rel = canonical(&arg_string(args, "to_path")?);
2036                // spec/mutate §6 (1.3): the default is `true`; `false` opts out.
2037                let retarget = arg_bool(args, "retarget_inbound")?.unwrap_or(true);
2038                let info = self.doc_info(&repo, &doc)?;
2039                let sugar::Sugar { effects, reason } =
2040                    sugar::move_document(&info.doc_id, &info.path, &to_rel, retarget);
2041                // `dangling`/`retargeted` as the library reported them: planned
2042                // over the current blocks before anything moves; the planner
2043                // lowers the same plan.
2044                let mut report: Option<(Vec<omgbase_store::InboundLink>, Option<Retargeted>)> =
2045                    None;
2046                let out = self.apply_sugar_with(&repo, root.as_deref(), &reason, |store, ds| {
2047                    if store.live_doc_at(&repo, &to_rel)? {
2048                        return Err(path_taken(format!("a document already exists at {to_rel}")));
2049                    }
2050                    let inbound = inbound_links_to(store.conn(), &repo, &info.doc_id, &info.path)?;
2051                    if ds.exists(&to_rel) {
2052                        return Err(path_taken(format!(
2053                            "file already exists on disk at {to_rel}"
2054                        )));
2055                    }
2056                    report = Some(if retarget && !inbound.is_empty() {
2057                        let p = store.plan_inbound_retarget(
2058                            &info.doc_id,
2059                            &info.path,
2060                            &to_rel,
2061                            &inbound,
2062                        )?;
2063                        (
2064                            p.dangling,
2065                            Some(Retargeted {
2066                                blocks: p.blocks,
2067                                docs: p.docs,
2068                            }),
2069                        )
2070                    } else {
2071                        (inbound, None)
2072                    });
2073                    Ok(effects)
2074                })?;
2075                let (dangling, retargeted) = report.expect("set before the effects were built");
2076                if out.1.is_none() {
2077                    // Previewing: the Plan alone (`spec/cli` §3.6).
2078                    return Ok(sugar_json(&out));
2079                }
2080                // The Receipt, then the link report (§4, the reference's key
2081                // order): `retargeted`, then each dangling link as a link row
2082                // under the `links` target's names (§1.2, 3.0) with its source
2083                // `path` rooted; its `target` stays as authored — that is what
2084                // the link says.
2085                let mut m = Map::new();
2086                m.insert(
2087                    "retargeted".to_owned(),
2088                    retargeted_json(retargeted.as_ref()),
2089                );
2090                m.insert(
2091                    "dangling".to_owned(),
2092                    Json::Array(dangling.iter().map(dangling_json).collect()),
2093                );
2094                let Json::Object(receipt) = sugar_json(&out) else {
2095                    unreachable!("a receipt is an object")
2096                };
2097                Ok(merge(receipt, Json::Object(m)))
2098            }
2099            "docs_delete" => {
2100                let (repo, root) = self.scope(args)?;
2101                let doc = self.locate_doc(&repo, name, args)?;
2102                let info = self.doc_info(&repo, &doc)?;
2103                let sugar::Sugar { effects, reason } =
2104                    sugar::delete_document(&info.doc_id, &info.path);
2105                self.apply_sugar(&repo, root.as_deref(), effects, &reason)
2106            }
2107            "docs_set_meta" => {
2108                let (repo, root) = self.scope(args)?;
2109                let doc = self.locate_doc(&repo, name, args)?;
2110                let set = arg_object(args, "set")?.cloned();
2111                let unset = if args.get("unset").is_some() {
2112                    arg_strings(args, "unset")?
2113                } else {
2114                    Vec::new()
2115                };
2116                let info = self.doc_info(&repo, &doc)?;
2117                // One `set`/`unset frontmatter.<key>` per key, `unset` winning
2118                // a key named in both (`sugar::set_meta`).
2119                let sugar::Sugar { effects, reason } =
2120                    sugar::set_meta(&info.doc_id, &info.path, set.as_ref(), &unset);
2121                if effects.is_empty() {
2122                    // Nothing to set or unset: the library path rewrote the
2123                    // frontmatter through the YAML emitter anyway (a
2124                    // normalizing no-op commit); with no effect to plan this is
2125                    // an argument error in both engines (3.3).
2126                    return Err(argument_error(
2127                        name,
2128                        "pass `set` and/or `unset`",
2129                        &spec.input_schema,
2130                    ));
2131                }
2132                self.apply_sugar(&repo, root.as_deref(), effects, &reason)
2133            }
2134            "docs_update" => {
2135                // Surface 4.0: sugar for `set { document, field: "content" }`
2136                // — the whole-document planner's opset lowered into one plan
2137                // and committed as one receipt (the identity decision is the
2138                // Plan's `preview.identity`; preview with `plan`). A content
2139                // already current plans nothing and commits nothing.
2140                let (repo, root) = self.scope(args)?;
2141                self.require_root(root.as_deref())?;
2142                let doc = self.locate_doc(&repo, name, args)?;
2143                let info = self.doc_info(&repo, &doc)?;
2144                let content = arg_string(args, "content")?;
2145                let reason = arg_str(args, "reason").map(str::to_owned);
2146                let origin = ApplyOrigin::new(&self.actor, reason.as_deref());
2147                let ctx = self.plan_ctx(&repo);
2148                let effects = sugar::update_content(&info.doc_id, &content);
2149                let preview = self.preview;
2150                let (plan, receipt) = self.with_writes(root.as_deref(), |store, ds| {
2151                    if preview {
2152                        return Ok((store.plan_effects(&ctx, &effects, ds)?, None));
2153                    }
2154                    let (plan, receipt) = store
2155                        .apply_effects_detailed(&ctx, &effects, &origin, ds)
2156                        .map_err(strip_effect_index)?;
2157                    Ok((plan, Some(receipt)))
2158                })?;
2159                self.last_plan = Some(plan.clone());
2160                if receipt.as_ref().is_some_and(|r| r.commit.is_some()) {
2161                    self.notify();
2162                }
2163                Ok(sugar_json(&(plan, receipt)))
2164            }
2165            "observe" => {
2166                let (repo, _) = self.scope(args)?;
2167                let path = storage_path(&arg_string(args, "path")?).to_owned();
2168                let content = arg_string(args, "content")?;
2169                let ts = self.now();
2170                let config = self.config.clone();
2171                let out = self
2172                    .store
2173                    .observe_one(&repo, &path, &content, &ts, &config)?;
2174                self.store.sweep_pool(&ts)?;
2175                self.notify();
2176                Ok(observe_json(&out))
2177            }
2178            "observe_many" => {
2179                let (repo, _) = self.scope(args)?;
2180                let files = args
2181                    .get("files")
2182                    .and_then(Json::as_array)
2183                    .ok_or_else(|| bad_args("`files` must be an array of {path, content}"))?;
2184                let items: Vec<omgbase_store::BatchItem> = files
2185                    .iter()
2186                    .map(|f| {
2187                        Ok(omgbase_store::BatchItem::observed(
2188                            storage_path(&arg_string(f, "path")?),
2189                            &arg_string(f, "content")?,
2190                        ))
2191                    })
2192                    .collect::<Result<_>>()?;
2193                let ts = self.now();
2194                let config = self.config.clone();
2195                let outcomes = self.store.observe_batch(&repo, &items, &ts, &config)?;
2196                self.store.sweep_pool(&ts)?;
2197                self.notify();
2198                let mut out = Vec::with_capacity(outcomes.len());
2199                for o in &outcomes {
2200                    match o.as_observed() {
2201                        Some(obs) => out.push(observe_json(obs)),
2202                        None => {
2203                            return Err(SurfaceError::other(format!(
2204                                "observe_many: unexpected outcome for {}",
2205                                o.path()
2206                            )));
2207                        }
2208                    }
2209                }
2210                Ok(Json::Array(out))
2211            }
2212            "observe_delete" => {
2213                let (repo, _) = self.scope(args)?;
2214                let path = storage_path(&arg_string(args, "path")?).to_owned();
2215                let ts = self.now();
2216                let out = self.store.observe_delete(&repo, &path, &ts)?;
2217                self.notify();
2218                Ok(
2219                    json!({ "id": out.doc_id, "path": reference_path(&out.path), "deleted": out.deleted() }),
2220                )
2221            }
2222            "blocks_history" => history::blocks_history(
2223                &self.store,
2224                &arg_string(args, "id")?,
2225                arg_i64(args, "limit")?,
2226            ),
2227            "docs_diff" => {
2228                let (repo, _) = self.scope(args)?;
2229                let doc_id = self.locate_doc(&repo, name, args)?;
2230                history::diff_blocks(
2231                    &self.store,
2232                    &doc_id,
2233                    &arg_string(args, "from_rev")?,
2234                    &arg_string(args, "to_rev")?,
2235                )
2236            }
2237            "docs_diff_unified" => {
2238                let (repo, _) = self.scope(args)?;
2239                let doc_ref = arg_str(args, "id")
2240                    .or(arg_str(args, "path"))
2241                    .unwrap_or_default()
2242                    .to_owned();
2243                let doc_id = self.locate_doc(&repo, name, args)?;
2244                let revs = history::recent_revs(self.store.conn(), &doc_id)?;
2245                let to_rev = arg_str(args, "to_rev")
2246                    .map(str::to_owned)
2247                    .or_else(|| revs.first().cloned());
2248                let from_rev = arg_str(args, "from_rev")
2249                    .map(str::to_owned)
2250                    .or_else(|| revs.get(1).cloned())
2251                    .or_else(|| revs.first().cloned());
2252                let (Some(from), Some(to)) = (from_rev, to_rev) else {
2253                    return Err(SurfaceError::new(
2254                        "target_missing",
2255                        format!("no revisions to diff for {}", Json::String(doc_ref)),
2256                    ));
2257                };
2258                let path: Option<String> = self
2259                    .store
2260                    .conn()
2261                    .query_row(
2262                        "SELECT path FROM docs WHERE doc_id = ?1",
2263                        params![doc_id],
2264                        |r| r.get(0),
2265                    )
2266                    .optional()?;
2267                let diff = history::diff_unified_text(&self.store, &doc_id, &from, &to)?;
2268                Ok(
2269                    json!({ "id": doc_id, "path": path.as_deref().map_or_else(String::new, reference_path), "from": from, "to": to, "diff": diff }),
2270                )
2271            }
2272            "docs_read_at" => {
2273                let (repo, _) = self.scope(args)?;
2274                let doc_id = self.locate_doc(&repo, name, args)?;
2275                let rev = arg_string(args, "rev")?;
2276                read::docs_read_at(&self.store, &doc_id, &rev)?.ok_or_else(|| {
2277                    SurfaceError::with_data(
2278                        "target_missing",
2279                        format!(
2280                            "no revision {} for document {doc_id}",
2281                            Json::String(rev.clone())
2282                        ),
2283                        json!({ "doc": doc_id, "rev": rev }),
2284                    )
2285                })
2286            }
2287            "docs_history" => {
2288                let (repo, _) = self.scope(args)?;
2289                let glob = arg_str(args, "path_glob").filter(|g| !g.is_empty());
2290                let id = arg_str(args, "id");
2291                let path = arg_str(args, "path");
2292                if glob.is_none() && id.is_none() && path.is_none() {
2293                    return Err(SurfaceError::new(
2294                        "target_missing",
2295                        "docs_history requires one of path_glob, id or path",
2296                    ));
2297                }
2298                let include_deleted = arg_bool(args, "include_deleted")?.unwrap_or(false);
2299                // A live document resolves through the locator; with
2300                // include_deleted a tombstoned one is looked up by the same attribute.
2301                let doc: Option<String> = if id.is_some() || path.is_some() {
2302                    let tomb: Option<String> = if include_deleted {
2303                        match (id, path) {
2304                            (Some(i), _) => self
2305                                .store
2306                                .conn()
2307                                .query_row(
2308                                    "SELECT doc_id FROM docs WHERE doc_id = ?1 AND repo_id = ?2",
2309                                    params![i, repo],
2310                                    |r| r.get(0),
2311                                )
2312                                .optional()?,
2313                            (None, Some(p)) => self
2314                                .store
2315                                .conn()
2316                                .query_row(
2317                                    "SELECT doc_id FROM docs WHERE repo_id = ?1 AND path = ?2",
2318                                    params![repo, storage_path(p)],
2319                                    |r| r.get(0),
2320                                )
2321                                .optional()?,
2322                            (None, None) => None,
2323                        }
2324                    } else {
2325                        None
2326                    };
2327                    Some(match tomb {
2328                        Some(d) => d,
2329                        None => self.locate_doc(&repo, name, args)?,
2330                    })
2331                } else {
2332                    None
2333                };
2334                history::docs_history(
2335                    &self.store,
2336                    &repo,
2337                    glob,
2338                    doc.as_deref(),
2339                    include_deleted,
2340                    arg_i64(args, "limit")?,
2341                )
2342            }
2343            "changes_since" => {
2344                let (repo, _) = self.scope(args)?;
2345                let page = self.store.changes_since(
2346                    &repo,
2347                    arg_i64(args, "cursor")?.unwrap_or(0),
2348                    arg_usize(args, "limit")?.unwrap_or(50),
2349                    arg_str(args, "origin").filter(|o| !o.is_empty()),
2350                )?;
2351                Ok(root_changes_page(page.to_json()))
2352            }
2353            "repos_status" => {
2354                let (repo, root) = self.scope(args)?;
2355                let fs = RealFileSystem;
2356                let disk = root
2357                    .as_deref()
2358                    .map(|r| (&fs as &dyn omgbase_sync::FileSystem, Path::new(r)));
2359                Ok(omgbase_sync::repos_status(&self.store, &repo, disk)?.to_json())
2360            }
2361            "sync_status" => {
2362                let (repo, root) = self.scope(args)?;
2363                let fs = RealFileSystem;
2364                let disk = root
2365                    .as_deref()
2366                    .map(|r| (&fs as &dyn omgbase_sync::FileSystem, Path::new(r)));
2367                Ok(omgbase_sync::sync_status(&self.store, &repo, disk)?.to_json())
2368            }
2369            "repos" => {
2370                let rows = self.repo_rows()?;
2371                Ok(json!({
2372                    "repos": rows.iter().map(|r| json!({ "slug": r.slug, "hasSource": r.root_path.is_some() })).collect::<Vec<_>>(),
2373                }))
2374            }
2375            "version" => version_info(Some(&self.store), &self.build_info),
2376            other => Err(SurfaceError::other(format!("unknown tool {other}"))),
2377        }
2378    }
2379}
2380
2381/// `block_missing` for a block the caller named (`id` or a ref): the
2382/// attribute spelling, the argument as given in `data`.
2383fn block_missing(value: &str) -> SurfaceError {
2384    SurfaceError::with_data(
2385        "block_missing",
2386        format!("no block with id {}", Json::String(value.to_owned())),
2387        json!({ "id": value }),
2388    )
2389}
2390
2391/// `nodes_block_read` on a node that names no live block.
2392fn node_has_no_block(node_id: &str, doc_id: &str) -> SurfaceError {
2393    SurfaceError::with_data(
2394        "block_missing",
2395        format!("node {} has no block", Json::String(node_id.to_owned())),
2396        json!({ "id": node_id, "doc_id": doc_id }),
2397    )
2398}
2399
2400/// The message every tool uses for a `semantic(...)` call with no provider.
2401pub const PROVIDER_UNAVAILABLE_MESSAGE: &str = "semantic(...) needs an embedding provider; none is configured — set embedding.provider (omg config set embedding.provider <command|url>) and embed the repository (omg embed drain)";
2402
2403/// `provider_unavailable` with no provider configured (§4, 3.0).
2404#[must_use]
2405pub fn provider_unavailable() -> SurfaceError {
2406    SurfaceError::with_data(
2407        "provider_unavailable",
2408        PROVIDER_UNAVAILABLE_MESSAGE,
2409        json!({ "provider": null }),
2410    )
2411}
2412
2413/// The `effects` argument (`spec/mutate` §11.1): a malformed effect is
2414/// `filter_invalid { reason, hint: "arguments" }` naming `effects[i]`.
2415fn parse_effects(v: &Json) -> Result<Vec<PublicEffect>> {
2416    PublicEffect::list_from_json(v).map_err(|m| SurfaceError::filter_invalid(m, "arguments"))
2417}
2418
2419/// A plan on the wire (`spec/mutate` §11): the digest-covered core as the
2420/// planner wrote it — a client echoes it back to `execute`, so no key moves
2421/// — and, under §1 "Paths", the preview's `revisions[].path` and `diffs`
2422/// keys in the reference form.
2423#[must_use]
2424pub fn plan_json(plan: &Plan) -> Json {
2425    let mut v = plan.to_json(true);
2426    if let Some(preview) = v.get_mut("preview").and_then(Json::as_object_mut) {
2427        if let Some(revs) = preview.get_mut("revisions").and_then(Json::as_array_mut) {
2428            for r in revs {
2429                root_field(r, "path");
2430            }
2431        }
2432        // The kernel results spell `merged_into` (the `spec/mutate` fixture
2433        // spelling); on the wire a result is `{ ids, removed?, mergedInto? }`,
2434        // as the reference's `OpResult` is — the 3.x apply result's spelling.
2435        if let Some(results) = preview.get_mut("results").and_then(Json::as_array_mut) {
2436            for r in results {
2437                if let Some(o) = r.as_object_mut() {
2438                    if let Some(m) = o.shift_remove("merged_into") {
2439                        o.insert("mergedInto".to_owned(), m);
2440                    }
2441                }
2442            }
2443        }
2444        if let Some(diffs) = preview.get_mut("diffs") {
2445            *diffs = reference_keyed(diffs);
2446        }
2447        // 4.0: a `retarget` effect's hits name their document's path.
2448        if let Some(retargets) = preview.get_mut("retarget").and_then(Json::as_array_mut) {
2449            for r in retargets {
2450                if let Some(hits) = r.get_mut("hits").and_then(Json::as_array_mut) {
2451                    for h in hits {
2452                        root_field(h, "path");
2453                    }
2454                }
2455            }
2456        }
2457    }
2458    v
2459}
2460
2461/// A receipt on the wire: `revisions[].path` in the reference form.
2462#[must_use]
2463pub fn receipt_json(receipt: &Receipt) -> Json {
2464    let mut v = receipt.to_json();
2465    if let Some(revs) = v.get_mut("revisions").and_then(Json::as_array_mut) {
2466        for r in revs {
2467            root_field(r, "path");
2468        }
2469    }
2470    v
2471}
2472
2473/// A dangling inbound link as an edge row (`spec/surface` §4 `docs_move`, 3.0):
2474/// `{ doc_id, path, block_id, target, anchor, field? }` — the source `path`
2475/// rooted, the `target` as authored.
2476fn dangling_json(l: &omgbase_store::InboundLink) -> Json {
2477    let mut m = Map::new();
2478    m.insert("doc_id".to_owned(), json!(l.doc));
2479    m.insert("path".to_owned(), json!(reference_path(&l.path)));
2480    m.insert("block_id".to_owned(), json!(l.block));
2481    m.insert("target".to_owned(), json!(l.target));
2482    m.insert("anchor".to_owned(), json!(l.anchor));
2483    if let Some(f) = &l.field {
2484        m.insert("field".to_owned(), json!(f));
2485    }
2486    Json::Object(m)
2487}
2488
2489/// `path_taken` as the library's document operations raise it: the message
2490/// alone, an empty `data` (`spec/surface` §7 pins `path_taken {}`).
2491fn path_taken(message: String) -> SurfaceError {
2492    SurfaceError::from(MutationError::new(ErrorCode::PathTaken, message))
2493}
2494
2495/// `changes_since` on the wire (§1 Paths, 2.0): the store renders its digests
2496/// in the storage form; every revision `path` is rooted here, and so is the
2497/// summary's subject — the reference composes `renderSummary` from rooted
2498/// paths, so the same line reads `observed: /a.md — 3 inserted`,
2499/// `api(human:spec): deleted /a.md`, `api(human:spec): moved /a.md → /b.md`,
2500/// `api(human:spec): moved /a.md → /b.md; /index.md — 31 edited` (a move
2501/// with its retarget, mutate 2.1). The subject is the text between the first
2502/// `: ` and the ` — ` detail (or the end): a comma-joined path list,
2503/// `deleted <paths>`, `moved <from> → <to>` or `moved <from> → <to>; <paths>`.
2504fn root_changes_page(mut page: Json) -> Json {
2505    let Some(digests) = page.get_mut("digests").and_then(Json::as_array_mut) else {
2506        return page;
2507    };
2508    for d in digests.iter_mut() {
2509        if let Some(revs) = d.get_mut("revisions").and_then(Json::as_array_mut) {
2510            for r in revs.iter_mut() {
2511                root_field(r, "path");
2512            }
2513        }
2514        if let Some(summary) = d.get("summary").and_then(Json::as_str) {
2515            let rooted = root_summary(summary);
2516            d["summary"] = Json::String(rooted);
2517        }
2518    }
2519    page
2520}
2521
2522/// The summary's subject paths in the reference form (see [`root_changes_page`]).
2523fn root_summary(summary: &str) -> String {
2524    let Some(colon) = summary.find(": ") else {
2525        return summary.to_owned();
2526    };
2527    let (head, rest) = summary.split_at(colon + 2);
2528    let (subject, detail) = match rest.find(" — ") {
2529        Some(i) => rest.split_at(i),
2530        None => (rest, ""),
2531    };
2532    let root_list = |s: &str| -> String {
2533        if s.is_empty() {
2534            return String::new();
2535        }
2536        s.split(", ")
2537            .map(reference_path)
2538            .collect::<Vec<_>>()
2539            .join(", ")
2540    };
2541    let subject = if let Some(paths) = subject.strip_prefix("deleted ") {
2542        format!("deleted {}", root_list(paths))
2543    } else if let Some(mv) = subject.strip_prefix("moved ") {
2544        // `moved <from> → <to>`, with the retarget's sources after a `; `.
2545        let (mv, rest) = match mv.split_once("; ") {
2546            Some((mv, rest)) => (mv, Some(rest)),
2547            None => (mv, None),
2548        };
2549        let moved = match mv.split_once(" → ") {
2550            Some((from, to)) => format!("moved {} → {}", reference_path(from), reference_path(to)),
2551            None => format!("moved {mv}"),
2552        };
2553        match rest {
2554            Some(rest) => format!("{moved}; {}", root_list(rest)),
2555            None => moved,
2556        }
2557    } else {
2558        root_list(subject)
2559    };
2560    format!("{head}{subject}{detail}")
2561}
2562
2563/// `spec/search` §4's evidence on the wire: `{ rrf, boosts, ftsRank?,
2564/// vectorRank?, cosine? }`.
2565fn evidence_json(e: &omgbase_store::Evidence) -> Json {
2566    let mut m = Map::new();
2567    m.insert("rrf".to_owned(), json!(e.rrf));
2568    m.insert("boosts".to_owned(), e.boosts.to_json());
2569    if let Some(r) = e.fts_rank {
2570        m.insert("ftsRank".to_owned(), json!(r));
2571    }
2572    if let Some(r) = e.vector_rank {
2573        m.insert("vectorRank".to_owned(), json!(r));
2574        if let Some(c) = e.cosine {
2575            m.insert("cosine".to_owned(), json!(c));
2576        }
2577    }
2578    Json::Object(m)
2579}
2580
2581/// The public `observe` result: `{ id, path, rev, commitId, converged,
2582/// echo, conflicted, dispositions: [{ kind, count }] }` — the store's own
2583/// `{ doc, commit, rev }` outcome (`spec/store` §9) re-shaped under the row
2584/// rule (§1.2, 3.0).
2585fn observe_json(o: &omgbase_store::ObserveOutcome) -> Json {
2586    json!({
2587        "id": o.doc_id,
2588        "path": reference_path(&o.path),
2589        "rev": o.rev,
2590        "commitId": o.commit_id,
2591        "converged": o.converged,
2592        "echo": o.echo,
2593        "conflicted": o.conflicted,
2594        "dispositions": o.dispositions.iter().map(|(k, n)| json!({ "kind": k, "count": n })).collect::<Vec<_>>(),
2595    })
2596}
2597
2598#[cfg(test)]
2599mod sugar_tests;
2600
2601#[cfg(test)]
2602mod tests {
2603    use super::*;
2604    use omgbase_store::{MemDocStore, SequentialMinter};
2605
2606    fn surface() -> Surface {
2607        let mut store =
2608            Store::open_in_memory_with_minter(Box::new(SequentialMinter::new())).unwrap();
2609        let repo = store.create_repo("fixture").unwrap();
2610        let mut n = 0;
2611        Surface::new(store, &repo, None)
2612            .with_doc_store(Box::new(MemDocStore::new()))
2613            .with_clock(move || {
2614                n += 1;
2615                format!("2026-09-26T10:{n:02}:00.000Z")
2616            })
2617    }
2618
2619    #[test]
2620    fn catalog_lists_every_tool_of_the_table() {
2621        let names: Vec<&str> = tools().iter().map(|t| t.name).collect();
2622        for want in [
2623            "docs_outline",
2624            "docs_read",
2625            "docs_read_many",
2626            "blocks_read",
2627            "nodes_block_read",
2628            "blocks_read_many",
2629            "docs_tree",
2630            "docs_list",
2631            "query_syntax",
2632            "query",
2633            "graph",
2634            "blocks_search",
2635            "blocks_resolve",
2636            "apply",
2637            "plan",
2638            "execute",
2639            "blocks_insert",
2640            "blocks_update",
2641            "blocks_move",
2642            "blocks_remove",
2643            "blocks_split",
2644            "blocks_merge",
2645            "blocks_check",
2646            "nodes_set",
2647            "blocks_append",
2648            "docs_append",
2649            "links_retarget",
2650            "links_stale",
2651            "links_repair",
2652            "docs_create",
2653            "docs_move",
2654            "docs_delete",
2655            "docs_set_meta",
2656            "docs_update",
2657            "observe",
2658            "observe_many",
2659            "observe_delete",
2660            "blocks_history",
2661            "docs_diff",
2662            "docs_diff_unified",
2663            "docs_read_at",
2664            "docs_history",
2665            "changes_since",
2666            "repos_status",
2667            "sync_status",
2668            "repos",
2669            "version",
2670        ] {
2671            assert!(names.contains(&want), "missing {want}");
2672        }
2673        assert_eq!(names.len(), 47);
2674        for t in tools() {
2675            assert_eq!(t.input_schema["type"], "object");
2676        }
2677    }
2678
2679    /// `spec/mutate` §11 at the surface: `plan` returns the frozen plan with
2680    /// a rooted preview, `execute` commits it (receipt with rooted paths),
2681    /// `apply` takes `ops` or `effects` (exactly one) and `effects` +
2682    /// `dry_run` is the plan; `execute` needs a plan or a digest + effects.
2683    #[test]
2684    fn plan_execute_and_apply_effects_round_trip() {
2685        let mut s = surface();
2686        let out = s.call(
2687            "observe",
2688            json!({ "path": "a.md", "content": "---\nstatus: open\n---\n\n# A\n\nAlpha.\n" }),
2689        );
2690        assert!(!out.is_error, "{}", out.body);
2691        let effects = json!([
2692            { "op": "set", "target": { "kind": "document", "path": "/a.md" }, "field": "frontmatter.status", "value": "done" },
2693            { "op": "insert", "into": { "kind": "section", "heading_id": "b_0" }, "markdown": "Tail." },
2694        ]);
2695        let plan = s.call("plan", json!({ "effects": effects }));
2696        assert!(!plan.is_error, "{}", plan.body);
2697        let plan = plan.body;
2698        assert_eq!(plan["version"], 1);
2699        assert_eq!(plan["plan_id"].as_str().map(str::len), Some(64));
2700        assert_eq!(
2701            plan["effects"][0]["target"],
2702            json!({ "kind": "document", "id": "d_0", "path": "a.md" }),
2703            "the core keeps storage paths"
2704        );
2705        assert_eq!(
2706            plan["preview"]["revisions"][0]["path"], "/a.md",
2707            "the preview is rooted"
2708        );
2709        assert!(
2710            plan["preview"]["diffs"].get("/a.md").is_some(),
2711            "{}",
2712            plan["preview"]["diffs"]
2713        );
2714        assert_eq!(
2715            plan["new_ids"],
2716            json!([{ "symbol": "$new.0", "kind": "block" }])
2717        );
2718        // 4.0: the retired inputs name their replacement (`spec/surface` §4);
2719        // `effects` is required; nothing is stripped silently.
2720        let dry = s.call("apply", json!({ "effects": effects, "dry_run": true }));
2721        assert_eq!(dry.body["error"], "filter_invalid", "{}", dry.body);
2722        assert_eq!(
2723            dry.body["message"],
2724            "`dry_run` is no longer an argument — preview with `plan { effects: [...] }` (apply takes effects, reason?, repo?)"
2725        );
2726        let ops = s.call("apply", json!({ "ops": [] }));
2727        assert_eq!(ops.body["error"], "filter_invalid");
2728        assert_eq!(
2729            ops.body["message"],
2730            "`ops` is no longer an argument — `apply` takes `effects` (the kernel ops are a library/fixture contract since spec/mutate 3.0) (apply takes effects, reason?, repo?)"
2731        );
2732        assert_eq!(ops.body["data"]["hint"], "arguments");
2733        let neither = s.call("apply", json!({}));
2734        assert_eq!(neither.body["error"], "filter_invalid");
2735        assert_eq!(
2736            neither.body["message"],
2737            "`effects` is required (apply takes effects, reason?, repo?)"
2738        );
2739        // The CLI's preview seam plans the same effects: the same plan, no
2740        // commit, nothing minted.
2741        let previewed = s
2742            .preview_call("apply", &json!({ "effects": effects }))
2743            .unwrap();
2744        assert_eq!(previewed["plan_id"], plan["plan_id"]);
2745        assert!(previewed.get("preview").is_some());
2746        let bad = s.call("apply", json!({ "effects": [{ "op": "nope" }] }));
2747        assert!(bad.is_error);
2748        assert_eq!(bad.body["error"], "filter_invalid");
2749        assert_eq!(bad.body["data"]["hint"], "arguments");
2750        assert!(
2751            bad.body["message"]
2752                .as_str()
2753                .unwrap()
2754                .starts_with("effects[0]:"),
2755            "{}",
2756            bad.body
2757        );
2758        // `execute` needs a plan or a digest + effects.
2759        let empty = s.call("execute", json!({}));
2760        assert!(empty.is_error);
2761        assert_eq!(empty.body["data"]["hint"], "arguments");
2762        // The plan, echoed back without its preview, commits.
2763        let mut echoed = plan.clone();
2764        echoed.as_object_mut().unwrap().remove("preview");
2765        let receipt = s.call("execute", json!({ "plan": echoed, "reason": "slice 1" }));
2766        assert!(!receipt.is_error, "{}", receipt.body);
2767        assert_eq!(receipt.body["plan_id"], plan["plan_id"]);
2768        assert_eq!(receipt.body["committed"], true);
2769        assert_eq!(receipt.body["commit"], "c_1");
2770        assert_eq!(receipt.body["revisions"][0]["path"], "/a.md");
2771        assert_eq!(receipt.body["new_ids"], json!({ "$new.0": "b_2" }));
2772        let read = s.call("docs_read", json!({ "path": "a.md" }));
2773        assert_eq!(
2774            read.body["raw"],
2775            "---\nstatus: done\n---\n\n# A\n\nAlpha.\n\nTail.\n"
2776        );
2777        // A replay is stale; the digest form with the same effects re-plans.
2778        let again = s.call("execute", json!({ "plan": echoed }));
2779        assert!(again.is_error);
2780        assert_eq!(again.body["error"], "stale_plan");
2781        assert_eq!(again.body["retriable"], true);
2782        let digest = s.call(
2783            "execute",
2784            json!({ "plan_id": plan["plan_id"], "effects": effects }),
2785        );
2786        assert!(digest.is_error);
2787        assert_eq!(digest.body["error"], "plan_mismatch", "{}", digest.body);
2788        let half = s.call("execute", json!({ "plan_id": plan["plan_id"] }));
2789        assert!(half.is_error);
2790        assert!(
2791            half.body["message"]
2792                .as_str()
2793                .unwrap()
2794                .starts_with("the digest form needs both"),
2795            "{}",
2796            half.body
2797        );
2798        let v2 = s.call(
2799            "execute",
2800            json!({ "plan": { "version": 2, "plan_id": "x" } }),
2801        );
2802        assert_eq!(v2.body["error"], "unsupported_plan_version", "{}", v2.body);
2803        let ops_count: i64 = s
2804            .store()
2805            .conn()
2806            .query_row(
2807                "SELECT count(*) FROM commits WHERE ops IS NOT NULL",
2808                [],
2809                |r| r.get(0),
2810            )
2811            .unwrap();
2812        assert_eq!(ops_count, 1, "the effect commit recorded its intent");
2813        assert!(Surface::is_write_tool("plan") && Surface::is_write_tool("execute"));
2814    }
2815
2816    #[test]
2817    fn observe_read_and_query_round_trip() {
2818        let mut s = surface();
2819        let out = s.call("observe", json!({ "path": "a.md", "content": "---\nlayer: canon\n---\n# Title\n\nHello world.\n\n- [ ] task one\n" }));
2820        assert!(!out.is_error, "{}", out.body);
2821        assert_eq!(out.body["id"], "d_0");
2822        assert_eq!(out.body["echo"], false);
2823        let read = s.call("docs_read", json!({ "path": "a.md", "include_ids": true }));
2824        assert!(!read.is_error, "{}", read.body);
2825        assert_eq!(read.body["path"], "/a.md");
2826        // either form names the document (§1 Paths)
2827        assert_eq!(
2828            s.call("docs_read", json!({ "path": "/a.md" })).body["path"],
2829            "/a.md"
2830        );
2831        // §4 "Arguments": the retired `doc` is refused with its replacement.
2832        let retired = s.call("docs_read", json!({ "doc": "a.md" }));
2833        assert_eq!(retired.body["error"], "filter_invalid");
2834        assert_eq!(
2835            retired.body["message"],
2836            "`doc` is no longer an argument — pass `id` or `path` (docs_read takes id?, include_ids?, path?, repo?)"
2837        );
2838        let neither = s.call("docs_read", json!({}));
2839        assert_eq!(
2840            neither.body["message"],
2841            "pass id or path (docs_read takes id?, include_ids?, path?, repo?)"
2842        );
2843        assert_eq!(
2844            s.call("docs_read", json!({ "path": "//a.md" })).body["id"],
2845            "d_0"
2846        );
2847        assert_eq!(read.body["properties"]["frontmatter"]["layer"], "canon");
2848        assert!(read.body["ids"].as_array().unwrap().len() >= 3);
2849        let q = s.call("query", json!({ "query": "select title, layer, t: nodes collect { value where kind == \"md:task\" } from docs where layer == \"canon\" && nodes count { where kind == \"md:task\" } == 1" }));
2850        assert!(!q.is_error, "{}", q.body);
2851        assert_eq!(q.body["hits"][0]["title"], "Title");
2852        assert_eq!(q.body["hits"][0]["layer"], "canon");
2853        assert_eq!(q.body["hits"][0]["t"][0]["value"], "task one");
2854        assert_eq!(q.body["hits"][0]["id"], "d_0");
2855        assert_eq!(q.body["hits"][0]["path"], "/a.md");
2856        assert_eq!(q.body["consumer"], "collect");
2857        let c = s.call(
2858            "query",
2859            json!({ "query": "blocks count { where text(\"hello\") }" }),
2860        );
2861        assert_eq!(c.body["count"], 1);
2862        // 3.0: a bare `path` is the attribute (no loud-error set); a
2863        // projection landing on the envelope must be aliased.
2864        let ok = s.call(
2865            "query",
2866            json!({ "query": "from docs where path == \"a.md\"" }),
2867        );
2868        assert!(!ok.is_error, "{}", ok.body);
2869        assert_eq!(ok.body["hits"][0]["id"], "d_0");
2870        let bad = s.call("query", json!({ "query": "select data.path from docs" }));
2871        assert!(bad.is_error);
2872        assert_eq!(bad.body["error"], "filter_invalid");
2873        assert_eq!(
2874            bad.body["message"],
2875            "projection `path` collides with the hit envelope — alias it (`p: data.path`)"
2876        );
2877        let sem = s.call(
2878            "query",
2879            json!({ "query": "from docs where semantic(\"x\") > 0.5" }),
2880        );
2881        assert_eq!(sem.body["error"], "provider_unavailable");
2882        assert_eq!(sem.body["data"], json!({ "provider": null }));
2883        let outline = s.call("docs_outline", json!({ "path": "a.md" }));
2884        let text = outline.body["text"].as_str().unwrap();
2885        assert!(text.starts_with("b_0 h1   Title  §"), "{text}");
2886        assert!(text.contains("☐ task one"));
2887        let missing = s.call("docs_read", json!({ "path": "nope.md" }));
2888        assert_eq!(missing.body["error"], "doc_missing");
2889        assert_eq!(
2890            missing.body["message"],
2891            "no document with id or path \"nope.md\""
2892        );
2893        assert_eq!(missing.body["data"], json!({ "path": "nope.md" }));
2894        let repos = s.call("repos", json!({}));
2895        assert_eq!(repos.body["repos"][0]["slug"], "fixture");
2896        let unknown = s.call("docs_list", json!({ "repo": "zzz" }));
2897        assert_eq!(unknown.body["error"], "repo_not_found");
2898    }
2899
2900    #[test]
2901    fn insert_and_move_carry_the_destination_parent_cas_and_many_reads_keep_one_item() {
2902        let mut s = surface();
2903        s.call(
2904            "observe",
2905            json!({ "path": "a.md", "content": "# T\n\nOne.\n\nTwo.\n" }),
2906        );
2907        s.call("observe", json!({ "path": "b.md", "content": "# U\n" }));
2908        // §4 (1.2): a stale `expect.parent_children_hash` on blocks_insert is
2909        // the kernel's destination-parent CAS: `stale_expectation` carrying the
2910        // current hash, from which the caller retries without a read.
2911        let stale = s.call(
2912            "blocks_insert",
2913            json!({ "to": "a.md", "markdown": "Three.", "expect": { "parent_children_hash": "00" } }),
2914        );
2915        assert!(stale.is_error, "{}", stale.body);
2916        assert_eq!(stale.body["error"], "stale_expectation");
2917        assert_eq!(stale.body["retriable"], true);
2918        let current = stale.body["data"]["current"]["parent_children_hash"]
2919            .as_str()
2920            .expect("current hash")
2921            .to_owned();
2922        assert_eq!(current.len(), 64, "{current}");
2923        let ok = s.call(
2924            "blocks_insert",
2925            json!({ "to": "a.md", "markdown": "Three.", "expect": { "parent_children_hash": current, "content_hash": "dropped, not checked" } }),
2926        );
2927        assert!(!ok.is_error, "{}", ok.body);
2928        assert_eq!(ok.body["committed"], true);
2929        let read = s.call("docs_read", json!({ "path": "a.md" }));
2930        assert_eq!(read.body["raw"], "# T\n\nOne.\n\nTwo.\n\nThree.\n");
2931        // blocks_move: the destination is checked once for the whole run.
2932        let stale = s.call(
2933            "blocks_move",
2934            json!({ "blocks": ["b_1", "b_2"], "to": "a.md", "at": "end", "expect": { "parent_children_hash": "00" } }),
2935        );
2936        assert_eq!(stale.body["error"], "stale_expectation", "{}", stale.body);
2937        let current = stale.body["data"]["current"]["parent_children_hash"]
2938            .as_str()
2939            .expect("current hash")
2940            .to_owned();
2941        let ok = s.call(
2942            "blocks_move",
2943            json!({ "blocks": ["b_1", "b_2"], "to": "a.md", "at": "end", "expect": { "parent_children_hash": current } }),
2944        );
2945        assert!(!ok.is_error, "{}", ok.body);
2946        let read = s.call("docs_read", json!({ "path": "a.md" }));
2947        assert_eq!(read.body["raw"], "# T\n\nThree.\n\nOne.\n\nTwo.\n\n");
2948        // The preview (`plan`, or the CLI's seam) still runs the CAS.
2949        let dry = s.call(
2950            "plan",
2951            json!({ "effects": [{ "op": "move", "targets": [{ "kind": "block", "id": "b_1" }], "into": { "kind": "document", "path": "a.md" }, "at": "start", "expect": { "parent_children_hash": "00" } }] }),
2952        );
2953        assert_eq!(dry.body["error"], "stale_expectation", "{}", dry.body);
2954        let dry = s
2955            .preview_call(
2956                "blocks_move",
2957                &json!({ "blocks": ["b_1"], "to": "a.md", "at": "start", "expect": { "parent_children_hash": "00" } }),
2958            )
2959            .unwrap_err();
2960        assert_eq!(dry.code, "stale_expectation");
2961        // §2 (1.2): the budget is applied from the second item on — one item
2962        // always comes back, `truncated` says more remained.
2963        let many = s.call(
2964            "docs_read_many",
2965            json!({ "docs": ["a.md", "b.md"], "budget_tokens": 1 }),
2966        );
2967        assert_eq!(
2968            many.body["items"].as_array().unwrap().len(),
2969            1,
2970            "{}",
2971            many.body
2972        );
2973        assert_eq!(many.body["items"][0]["path"], "/a.md");
2974        assert_eq!(many.body["truncated"], true);
2975        let one = s.call(
2976            "docs_read_many",
2977            json!({ "docs": ["b.md"], "budget_tokens": 1 }),
2978        );
2979        assert_eq!(one.body["items"].as_array().unwrap().len(), 1);
2980        assert_eq!(one.body["truncated"], false);
2981        let nodes = s.call(
2982            "blocks_read_many",
2983            json!({ "ids": ["b_zzz", "b_1", "b_2"], "resolution": "full", "budget_tokens": 1 }),
2984        );
2985        assert_eq!(
2986            nodes.body["nodes"].as_array().unwrap().len(),
2987            1,
2988            "{}",
2989            nodes.body
2990        );
2991        assert_eq!(nodes.body["nodes"][0]["id"], "b_1");
2992        assert_eq!(nodes.body["truncated"], true);
2993        assert_eq!(nodes.body["unresolved"], json!(["b_zzz"]));
2994    }
2995
2996    #[test]
2997    fn writes_go_through_the_fixed_doc_store_and_fire_the_hook() {
2998        use std::cell::Cell;
2999        use std::rc::Rc;
3000        let fired = Rc::new(Cell::new(0));
3001        let f2 = Rc::clone(&fired);
3002        let mut s = surface().with_mutation_hook(move || f2.set(f2.get() + 1));
3003        s.call(
3004            "observe",
3005            json!({ "path": "a.md", "content": "# T\n\nOne.\n" }),
3006        );
3007        assert_eq!(fired.get(), 1);
3008        let dry = s
3009            .preview_call(
3010                "blocks_insert",
3011                &json!({ "to": "a.md", "markdown": "Two." }),
3012            )
3013            .unwrap();
3014        assert!(dry.get("committed").is_none(), "a Plan: {dry}");
3015        assert_eq!(
3016            dry["new_ids"],
3017            json!([{ "symbol": "$new.0", "kind": "block" }])
3018        );
3019        assert_eq!(fired.get(), 1, "a preview never fires the hook");
3020        let plan = s.call("plan", json!({ "effects": [{ "op": "insert", "into": { "kind": "document", "path": "a.md" }, "markdown": "Two." }] }));
3021        assert_eq!(
3022            plan.body["plan_id"], dry["plan_id"],
3023            "the preview IS the plan"
3024        );
3025        assert_eq!(fired.get(), 1, "`plan` never fires the hook");
3026        let wet = s.call("blocks_insert", json!({ "to": "a.md", "markdown": "Two." }));
3027        assert!(!wet.is_error, "{}", wet.body);
3028        assert_eq!(fired.get(), 2);
3029        // The Receipt (4.0), the preview's symbol realized.
3030        assert_eq!(wet.body["plan_id"], dry["plan_id"]);
3031        assert_eq!(wet.body["committed"], true);
3032        assert_eq!(wet.body["commit"], "c_1");
3033        assert_eq!(wet.body["new_ids"], json!({ "$new.0": "b_2" }));
3034        assert_eq!(wet.body["revisions"][0]["path"], "/a.md");
3035        assert!(wet.body.get("results").is_none() && wet.body.get("diffs").is_none());
3036        let read = s.call("docs_read", json!({ "id": "d_0" }));
3037        assert_eq!(read.body["raw"], "# T\n\nOne.\n\nTwo.\n");
3038        let upd = s.call(
3039            "blocks_update",
3040            json!({ "id": "b_1", "markdown": "One, edited." }),
3041        );
3042        assert!(!upd.is_error, "{}", upd.body);
3043        assert!(
3044            upd.body.get("id").is_none() && upd.body.get("ids").is_none(),
3045            "{}",
3046            upd.body
3047        );
3048        assert_eq!(upd.body["revisions"].as_array().map(Vec::len), Some(1));
3049        let attrs = s.call(
3050            "blocks_update",
3051            json!({ "id": "b_1", "attrs": { "checked": true } }),
3052        );
3053        assert_eq!(
3054            attrs.body["message"],
3055            "`attrs` is no longer an argument — pass `checked` (blocks_update takes checked?, expect?, id, markdown?, repo?)"
3056        );
3057        let dry = s.call(
3058            "blocks_update",
3059            json!({ "id": "b_1", "markdown": "x", "dry_run": true }),
3060        );
3061        assert_eq!(
3062            dry.body["message"],
3063            "`dry_run` is no longer an argument — preview with `plan { effects: [...] }` (blocks_update takes checked?, expect?, id, markdown?, repo?)"
3064        );
3065        let nothing = s.call("blocks_update", json!({ "id": "b_1" }));
3066        assert_eq!(
3067            nothing.body["message"],
3068            "pass `markdown` and/or `checked` (blocks_update takes checked?, expect?, id, markdown?, repo?)"
3069        );
3070        let app = s.call(
3071            "docs_append",
3072            json!({ "path": "a.md", "section": "T", "text": "Three." }),
3073        );
3074        assert!(!app.is_error, "{}", app.body);
3075        let amb = s.call(
3076            "docs_append",
3077            json!({ "path": "a.md", "section": "Nope", "text": "x" }),
3078        );
3079        assert_eq!(amb.body["error"], "parent_missing");
3080        assert_eq!(amb.body["message"], "no heading \"Nope\" in /a.md");
3081        // blocks_append: a heading's section, a leaf is block_not_container.
3082        let into = s.call("blocks_append", json!({ "id": "b_0", "text": "Four." }));
3083        assert!(!into.is_error, "{}", into.body);
3084        let leaf = s.call("blocks_append", json!({ "id": "b_1", "text": "x" }));
3085        assert_eq!(leaf.body["error"], "block_not_container", "{}", leaf.body);
3086        assert_eq!(
3087            leaf.body["data"],
3088            json!({ "id": "b_1", "type": "paragraph" })
3089        );
3090        let hist = s.call("blocks_history", json!({ "id": "b_1" }));
3091        let entries = hist.body.as_array().unwrap();
3092        assert!(entries.len() >= 2, "{}", hist.body);
3093        assert_eq!(entries[0]["origin"], "api", "newest first");
3094        let du = s.call("docs_diff_unified", json!({ "path": "a.md" }));
3095        assert!(!du.is_error, "{}", du.body);
3096        assert!(du.body["diff"].as_str().unwrap().contains("+Four."));
3097    }
3098
3099    /// The document operations (§4, 4.0): each answers with the Receipt —
3100    /// `docs_move` adding `dangling`/`retargeted` — and previews through
3101    /// `plan` (the CLI's seam runs the tool's own checks): a create `"" →
3102    /// bytes`, a move two entries + the rewritten sources, a delete `bytes →
3103    /// ""`, a meta patch before → after; a preview commits nothing, fires no
3104    /// hook and burns no id.
3105    #[test]
3106    fn doc_tools_return_receipts_and_preview_through_plan() {
3107        use std::cell::Cell;
3108        use std::rc::Rc;
3109        let fired = Rc::new(Cell::new(0));
3110        let f2 = Rc::clone(&fired);
3111        let mut s = surface().with_mutation_hook(move || f2.set(f2.get() + 1));
3112        let b_src = "# B\n\nTarget.\n";
3113        let a_src = "# A\n\nSee [b](b.md).\n";
3114        let b = s.call("docs_create", json!({ "path": "b.md", "markdown": b_src }));
3115        assert!(!b.is_error, "{}", b.body);
3116        assert_eq!(
3117            b.body,
3118            json!({ "plan_id": b.body["plan_id"], "commit": "c_0", "revisions": [{ "id": "d_0", "path": "/b.md", "hash": b.body["revisions"][0]["hash"] }], "new_ids": { "$new.0": "d_0", "$new.1": "b_0", "$new.2": "b_1" }, "committed": true }),
3119            "the created document is revisions[0], its symbols the document then its blocks"
3120        );
3121        let a = s.call("docs_create", json!({ "path": "a.md", "markdown": a_src }));
3122        assert_eq!(a.body["new_ids"]["$new.0"], "d_1", "{}", a.body);
3123        assert_eq!(fired.get(), 2);
3124        let count = |s: &Surface, table: &str| -> i64 {
3125            s.store
3126                .conn()
3127                .query_row(&format!("SELECT count(*) FROM {table}"), [], |r| r.get(0))
3128                .unwrap()
3129        };
3130        let snapshot = |s: &Surface| {
3131            (
3132                count(s, "docs"),
3133                count(s, "commits"),
3134                count(s, "revisions"),
3135                count(s, "blocks"),
3136            )
3137        };
3138        let before = snapshot(&s);
3139        let keys = |v: &Json| -> Vec<String> { v.as_object().unwrap().keys().cloned().collect() };
3140
3141        // docs_create preview: `""` → the composed file; the `d_` is a symbol.
3142        let c = s
3143            .preview_call(
3144                "docs_create",
3145                &json!({ "path": "c.md", "markdown": "# C", "frontmatter": { "title": "C" } }),
3146            )
3147            .unwrap();
3148        assert_eq!(
3149            c["preview"]["diffs"],
3150            json!({ "/c.md": { "before": "", "after": "---\ntitle: C\n---\n\n# C\n" } })
3151        );
3152        assert_eq!(c["preview"]["revisions"][0]["id"], "$new.0");
3153        assert_eq!(
3154            c["new_ids"],
3155            json!([{ "symbol": "$new.0", "kind": "document" }, { "symbol": "$new.1", "kind": "block" }])
3156        );
3157
3158        // docs_move preview with retarget_inbound: the rewritten source, the
3159        // old path emptied, the new path filled — the planner's order.
3160        let m = s
3161            .preview_call(
3162                "docs_move",
3163                &json!({ "path": "b.md", "to_path": "notes/b.md", "retarget_inbound": true }),
3164            )
3165            .unwrap();
3166        assert_eq!(
3167            keys(&m["preview"]["diffs"]),
3168            ["/a.md", "/b.md", "/notes/b.md"]
3169        );
3170        assert_eq!(
3171            m["preview"]["diffs"]["/a.md"],
3172            json!({ "before": a_src, "after": "# A\n\nSee [b](notes/b.md).\n" })
3173        );
3174        assert_eq!(
3175            m["preview"]["diffs"]["/b.md"],
3176            json!({ "before": b_src, "after": "" })
3177        );
3178        assert_eq!(
3179            m["preview"]["diffs"]["/notes/b.md"],
3180            json!({ "before": "", "after": b_src })
3181        );
3182        assert!(
3183            m.get("dangling").is_none(),
3184            "a preview is the Plan alone: {m}"
3185        );
3186        // … and with `retarget_inbound: false`: two entries.
3187        let plain = s
3188            .preview_call(
3189                "docs_move",
3190                &json!({ "path": "b.md", "to_path": "notes/b.md", "retarget_inbound": false }),
3191            )
3192            .unwrap();
3193        assert_eq!(keys(&plain["preview"]["diffs"]), ["/b.md", "/notes/b.md"]);
3194
3195        // docs_delete preview: the file → `""`.
3196        let d = s
3197            .preview_call("docs_delete", &json!({ "path": "a.md" }))
3198            .unwrap();
3199        assert_eq!(
3200            d["preview"]["diffs"],
3201            json!({ "/a.md": { "before": a_src, "after": "" } })
3202        );
3203
3204        // docs_set_meta preview: before → after.
3205        let sm = s
3206            .preview_call(
3207                "docs_set_meta",
3208                &json!({ "path": "a.md", "set": { "status": "open" } }),
3209            )
3210            .unwrap();
3211        assert_eq!(
3212            sm["preview"]["diffs"],
3213            json!({ "/a.md": { "before": a_src, "after": "---\nstatus: open\n---\n\n# A\n\nSee [b](b.md).\n" } })
3214        );
3215
3216        // Nothing committed, no hook, no row changed, no id burned; the
3217        // checks still run; `dry_run` is a retired name.
3218        assert_eq!(fired.get(), 2, "previews never fire the mutation hook");
3219        assert_eq!(snapshot(&s), before);
3220        let taken = s.preview_call("docs_create", &json!({ "path": "a.md", "markdown": "x" }));
3221        assert_eq!(taken.unwrap_err().code, "path_taken");
3222        let missing = s.preview_call("docs_delete", &json!({ "path": "nope.md" }));
3223        assert_eq!(missing.unwrap_err().code, "doc_missing");
3224        let dry = s.call("docs_delete", json!({ "path": "a.md", "dry_run": true }));
3225        assert_eq!(
3226            dry.body["message"],
3227            "`dry_run` is no longer an argument — preview with `plan { effects: [...] }` (docs_delete takes id?, path?, repo?)"
3228        );
3229        let real = s.call(
3230            "docs_create",
3231            json!({ "path": "c.md", "markdown": "# C\n" }),
3232        );
3233        assert_eq!(
3234            real.body["new_ids"]["$new.0"], "d_2",
3235            "the preview burned no `d`"
3236        );
3237        assert_eq!(fired.get(), 3);
3238
3239        // The committed move: the Receipt (the rewritten source's revision),
3240        // then `dangling` and `retargeted`.
3241        let moved = s.call(
3242            "docs_move",
3243            json!({ "path": "b.md", "to_path": "notes/b.md" }),
3244        );
3245        assert!(!moved.is_error, "{}", moved.body);
3246        assert_eq!(
3247            keys(&moved.body),
3248            [
3249                "plan_id",
3250                "commit",
3251                "revisions",
3252                "new_ids",
3253                "committed",
3254                "retargeted",
3255                "dangling"
3256            ]
3257        );
3258        assert_eq!(
3259            moved.body["revisions"],
3260            json!([{ "id": "d_1", "path": "/a.md", "hash": moved.body["revisions"][0]["hash"] }])
3261        );
3262        assert_eq!(moved.body["dangling"], json!([]));
3263        assert_eq!(
3264            moved.body["retargeted"],
3265            json!({ "blocks": ["b_3"], "docs": ["d_1"] })
3266        );
3267        assert_eq!(
3268            s.call("docs_read", json!({ "id": "d_0" })).body["path"],
3269            "/notes/b.md"
3270        );
3271        // Opted out: the link dangles, as a link row with its source rooted.
3272        let back = s.call(
3273            "docs_move",
3274            json!({ "path": "notes/b.md", "to_path": "b.md", "retarget_inbound": false }),
3275        );
3276        assert_eq!(back.body["revisions"], json!([]), "{}", back.body);
3277        assert_eq!(
3278            back.body["dangling"],
3279            json!([{ "doc_id": "d_1", "path": "/a.md", "block_id": "b_3", "target": "notes/b.md", "anchor": null }])
3280        );
3281        assert_eq!(back.body["retargeted"], Json::Null);
3282        // A set_meta and a delete: Receipts; the delete has no revision.
3283        let sm = s.call(
3284            "docs_set_meta",
3285            json!({ "path": "a.md", "set": { "status": "open" } }),
3286        );
3287        assert_eq!(
3288            sm.body["revisions"].as_array().map(Vec::len),
3289            Some(1),
3290            "{}",
3291            sm.body
3292        );
3293        assert_eq!(sm.body["committed"], true);
3294        let d = s.call("docs_delete", json!({ "path": "a.md" }));
3295        assert_eq!(d.body["revisions"], json!([]), "{}", d.body);
3296        assert!(d.body["commit"].is_string());
3297        assert_eq!(
3298            s.call("docs_read", json!({ "path": "a.md" })).body["error"],
3299            "doc_missing"
3300        );
3301    }
3302
3303    #[test]
3304    fn change_summaries_are_rooted_like_the_reference_renders_them() {
3305        assert_eq!(
3306            root_summary("observed: a.md, b/c.md — 3 inserted, 1 edited"),
3307            "observed: /a.md, /b/c.md — 3 inserted, 1 edited"
3308        );
3309        assert_eq!(
3310            root_summary("observed: deleted a.md"),
3311            "observed: deleted /a.md"
3312        );
3313        assert_eq!(
3314            root_summary("api(human:spec): moved a.md → b/a.md"),
3315            "api(human:spec): moved /a.md → /b/a.md"
3316        );
3317        assert_eq!(
3318            root_summary("api(human:spec): moved a.md → b/a.md; index.md, c.md — 31 edited"),
3319            "api(human:spec): moved /a.md → /b/a.md; /index.md, /c.md — 31 edited"
3320        );
3321        assert_eq!(root_summary("api(?): x.md"), "api(?): /x.md");
3322        assert_eq!(root_summary("observed: "), "observed: ");
3323        assert_eq!(root_summary("nonsense"), "nonsense");
3324        let page = root_changes_page(json!({
3325            "digests": [{ "summary": "observed: a.md", "revisions": [{ "id": "d_0", "path": "a.md", "hash": "ff" }] }],
3326            "cursor": 1, "truncated": false, "head": 1
3327        }));
3328        assert_eq!(page["digests"][0]["revisions"][0]["path"], "/a.md");
3329        assert_eq!(page["digests"][0]["summary"], "observed: /a.md");
3330    }
3331}