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
8use std::path::Path;
9
10use omgbase_format::BlockKind;
11use omgbase_format::text::{normalize_text, normalize_visible_text};
12use omgbase_reconcile::Config;
13use omgbase_search::EmbeddingProvider;
14use omgbase_store::docs_ops::diffs_json;
15use omgbase_store::mutate_kernel::{At, Op, Parent, To};
16use omgbase_store::{
17    ApplyOrigin, ApplyRequest, ApplyResult, DocOpContext, DocStore, Expect, FsDocStore, Opset,
18    QueryVector, Store,
19};
20use omgbase_sync::{RealFileSystem, RepoRow};
21use rusqlite::{OptionalExtension, params};
22use serde_json::{Map, Value as Json, json};
23
24use crate::error::{Result, SurfaceError};
25use crate::graph::{GraphArgs, graph_neighborhood};
26use crate::history;
27use crate::links;
28use crate::query::{QueryOptions, query};
29use crate::read::{self, Resolution, ResolvedRef};
30use crate::reference::QUERY_SYNTAX;
31use crate::version::{BuildInfo, version_info};
32
33/// The actor every write from this surface records.
34pub const ACTOR: &str = "agent:mcp";
35
36/// One tool of the catalog.
37#[derive(Clone, Debug, PartialEq)]
38pub struct ToolSpec {
39    pub name: &'static str,
40    pub description: &'static str,
41    /// The JSON schema of the arguments object.
42    pub input_schema: Json,
43}
44
45/// What a tool call produced: its JSON result, or the error envelope with
46/// `is_error` set.
47#[derive(Clone, Debug, PartialEq)]
48pub struct ToolOutcome {
49    pub body: Json,
50    pub is_error: bool,
51}
52
53/// Where a mutating tool writes files.
54enum WriteTarget {
55    /// Derive the repo's root from its `fs` source and write through the
56    /// filesystem (a sourceless repo cannot mutate).
57    Derived,
58    /// Write through this store whatever the repo's sources say (a runner's
59    /// in-memory store).
60    Fixed(Box<dyn DocStore>),
61}
62
63/// The catalog bound to a store, a default repo and optional providers.
64pub struct Surface {
65    store: Store,
66    default_repo: String,
67    provider: Option<Box<dyn EmbeddingProvider>>,
68    on_mutation: Option<Box<dyn FnMut()>>,
69    clock: Box<dyn FnMut() -> String>,
70    writes: WriteTarget,
71    config: Config,
72    /// The actor every write records (`ACTOR` unless a client names one).
73    actor: String,
74    /// What the serving binary knows about itself (the `version` tool).
75    build_info: BuildInfo,
76}
77
78// ---- argument helpers ----------------------------------------------------------------
79
80fn bad_args(msg: impl Into<String>) -> SurfaceError {
81    SurfaceError::filter_invalid(msg, "arguments")
82}
83
84fn arg_str<'a>(args: &'a Json, key: &str) -> Option<&'a str> {
85    args.get(key).and_then(Json::as_str)
86}
87
88fn arg_string(args: &Json, key: &str) -> Result<String> {
89    arg_str(args, key)
90        .map(str::to_owned)
91        .ok_or_else(|| bad_args(format!("`{key}` must be a string")))
92}
93
94fn arg_i64(args: &Json, key: &str) -> Result<Option<i64>> {
95    match args.get(key) {
96        None | Some(Json::Null) => Ok(None),
97        Some(v) => v
98            .as_f64()
99            .filter(|n| n.fract() == 0.0)
100            .map(|n| Some(n as i64))
101            .ok_or_else(|| bad_args(format!("`{key}` must be an integer"))),
102    }
103}
104
105fn arg_usize(args: &Json, key: &str) -> Result<Option<usize>> {
106    Ok(arg_i64(args, key)?.map(|n| usize::try_from(n).unwrap_or(0)))
107}
108
109fn arg_bool(args: &Json, key: &str) -> Result<Option<bool>> {
110    match args.get(key) {
111        None | Some(Json::Null) => Ok(None),
112        Some(Json::Bool(b)) => Ok(Some(*b)),
113        Some(_) => Err(bad_args(format!("`{key}` must be a boolean"))),
114    }
115}
116
117fn arg_strings(args: &Json, key: &str) -> Result<Vec<String>> {
118    let Some(arr) = args.get(key).and_then(Json::as_array) else {
119        return Err(bad_args(format!("`{key}` must be an array of strings")));
120    };
121    arr.iter()
122        .map(|v| {
123            v.as_str()
124                .map(str::to_owned)
125                .ok_or_else(|| bad_args(format!("`{key}` must be an array of strings")))
126        })
127        .collect()
128}
129
130fn arg_object<'a>(args: &'a Json, key: &str) -> Result<Option<&'a Map<String, Json>>> {
131    match args.get(key) {
132        None | Some(Json::Null) => Ok(None),
133        Some(Json::Object(o)) => Ok(Some(o)),
134        Some(_) => Err(bad_args(format!("`{key}` must be an object"))),
135    }
136}
137
138fn hex_field(e: &Map<String, Json>, k: &str) -> Option<String> {
139    e.get(k).and_then(Json::as_str).map(str::to_owned)
140}
141
142/// A block tool's `expect { content_hash?, parent_children_hash? }` (the
143/// reference's `expectSchema`, both keys optional), or `None` when absent.
144fn arg_expect(args: &Json) -> Result<Option<Expect>> {
145    Ok(arg_object(args, "expect")?.map(|e| Expect {
146        content_hash: hex_field(e, "content_hash"),
147        parent_children_hash: hex_field(e, "parent_children_hash"),
148    }))
149}
150
151/// `blocks_insert` / `blocks_move`'s `expect { parent_children_hash? }` — the
152/// destination-parent CAS of `spec/mutate` §1.2 (1.2). A `content_hash` has
153/// no meaning on these ops and is dropped, not an error (the reference's zod
154/// schema strips it).
155fn arg_parent_expect(args: &Json) -> Result<Option<Expect>> {
156    Ok(arg_object(args, "expect")?.map(|e| Expect {
157        content_hash: None,
158        parent_children_hash: hex_field(e, "parent_children_hash"),
159    }))
160}
161
162fn resolution_arg(args: &Json, default: Resolution) -> Result<Resolution> {
163    match arg_str(args, "resolution") {
164        None => Ok(default),
165        Some(s) => Resolution::parse(s).ok_or_else(|| {
166            bad_args("`resolution` must be one of skeleton, outline, text, raw, full")
167        }),
168    }
169}
170
171/// `{ ...a, ...b }` (b wins).
172fn merge(mut a: Map<String, Json>, b: Json) -> Json {
173    if let Json::Object(o) = b {
174        for (k, v) in o {
175            a.insert(k, v);
176        }
177    }
178    Json::Object(a)
179}
180
181fn schema(props: &[(&str, Json)], required: &[&str], repo: bool) -> Json {
182    let mut p = Map::new();
183    for (k, v) in props {
184        p.insert((*k).to_owned(), v.clone());
185    }
186    if repo {
187        p.insert("repo".to_owned(), json!({ "type": "string" }));
188    }
189    json!({ "type": "object", "properties": p, "required": required })
190}
191
192fn s() -> Json {
193    json!({ "type": "string" })
194}
195fn i() -> Json {
196    json!({ "type": "integer" })
197}
198fn b() -> Json {
199    json!({ "type": "boolean" })
200}
201fn strings() -> Json {
202    json!({ "type": "array", "items": { "type": "string" } })
203}
204fn obj() -> Json {
205    json!({ "type": "object" })
206}
207fn nullable_string() -> Json {
208    json!({ "type": ["string", "null"] })
209}
210fn resolution_schema() -> Json {
211    json!({ "type": "string", "enum": ["skeleton", "outline", "text", "raw", "full"] })
212}
213fn at_schema() -> Json {
214    json!({ "oneOf": [
215        { "const": "start" }, { "const": "end" },
216        { "type": "object", "properties": { "before": { "type": "string" } }, "required": ["before"] },
217        { "type": "object", "properties": { "after": { "type": "string" } }, "required": ["after"] }
218    ] })
219}
220fn expect_schema() -> Json {
221    json!({ "type": "object", "properties": { "content_hash": { "type": "string" }, "parent_children_hash": { "type": "string" } } })
222}
223/// `blocks_insert` / `blocks_move` carry the op-level expectation of
224/// `spec/mutate` §1.2: only `parent_children_hash` has a meaning there (no
225/// target block whose content could be checked), so the schema names that
226/// key alone, as the reference's `parentExpectSchema`.
227fn parent_expect_schema() -> Json {
228    json!({ "type": "object", "properties": { "parent_children_hash": { "type": "string" } } })
229}
230
231/// The catalog (§4 table), in the reference's registration order.
232#[must_use]
233pub fn tools() -> Vec<ToolSpec> {
234    let dp = |extra: &[(&str, Json)], required: &[&str]| {
235        let mut props = vec![("doc", s()), ("path", s())];
236        props.extend(extra.iter().cloned());
237        schema(&props, required, true)
238    };
239    vec![
240        ToolSpec {
241            name: "docs_outline",
242            description: "A document's compact indented outline (id, type, label per line; § marks headings). Args take a doc id or path.",
243            input_schema: dp(
244                &[
245                    (
246                        "resolution",
247                        json!({ "type": "string", "enum": ["skeleton", "outline"] }),
248                    ),
249                    ("depth", i()),
250                    ("budget_tokens", i()),
251                ],
252                &[],
253            ),
254        },
255        ToolSpec {
256            name: "docs_read",
257            description: "Read a whole document: verbatim `content`, `properties` grouped by source, `path`/`docId`/`rev`; include_ids adds `ids`, `hashes` (CAS tokens) and `parents`.",
258            input_schema: dp(&[("include_ids", b())], &[]),
259        },
260        ToolSpec {
261            name: "docs_get_many",
262            description: "Batch docs_read over `docs` (ids or paths): `{ items, errors, truncated }`, capped at 100 refs, optional token budget.",
263            input_schema: schema(
264                &[
265                    ("docs", strings()),
266                    ("include_ids", b()),
267                    ("budget_tokens", i()),
268                ],
269                &["docs"],
270                true,
271            ),
272        },
273        ToolSpec {
274            name: "nodes_get",
275            description: "Hydrate one block subtree at a resolution (skeleton|outline|text|raw|full); the owning doc is inferred from `id` when `doc`/`path` are omitted.",
276            input_schema: dp(&[("id", s()), ("resolution", resolution_schema())], &["id"]),
277        },
278        ToolSpec {
279            name: "nodes_get_many",
280            description: "Fetch up to 100 blocks by id in request order with budget truncation; `doc`/`path` is an optional scope. Returns `nodes`, `truncated`, `unresolved`.",
281            input_schema: dp(
282                &[
283                    ("ids", strings()),
284                    ("resolution", resolution_schema()),
285                    ("budget_tokens", i()),
286                ],
287                &["ids"],
288            ),
289        },
290        ToolSpec {
291            name: "read_ref",
292            description: "Read any ref — a document (id or path) or a block (`b_` id, or an `n_` node id) — classified as `{ kind: \"document\", … }` or `{ kind: \"block\", … }` (block `resolution` defaults to raw).",
293            input_schema: schema(
294                &[("ref", s()), ("resolution", resolution_schema())],
295                &["ref"],
296                true,
297            ),
298        },
299        ToolSpec {
300            name: "docs_tree",
301            description: "The directory-aware shape of a repo: live docs under `path` collapsed at `depth` segments into dir/doc entries with totals, ordered by path and paged.",
302            input_schema: schema(
303                &[
304                    ("path", s()),
305                    ("depth", i()),
306                    ("limit", i()),
307                    ("cursor", nullable_string()),
308                    ("budget_tokens", i()),
309                ],
310                &[],
311                true,
312            ),
313        },
314        ToolSpec {
315            name: "docs_list",
316            description: "Enumerate live documents as a page `{ items: [{ path, blocks, ts }], truncated, cursor }`, ordered by path; `path_glob` is a LIKE match (`*` matches across `/`).",
317            input_schema: schema(
318                &[
319                    ("path_glob", s()),
320                    ("limit", i()),
321                    ("cursor", nullable_string()),
322                    ("budget_tokens", i()),
323                ],
324                &[],
325                true,
326            ),
327        },
328        ToolSpec {
329            name: "query_syntax",
330            description: "The OQX syntax reference for the `query` tool.",
331            input_schema: schema(&[], &[], false),
332        },
333        ToolSpec {
334            name: "query",
335            description: "Run one OQX query (`select … from docs|blocks|nodes|edges where … follow … order by … limit N`). Returns lean hits `{ id, path, …projections }` with `truncated` + `cursor`, or a `count`/`exists`/`none` scalar, or `values`. See query_syntax.",
336            input_schema: schema(
337                &[
338                    ("query", s()),
339                    ("limit", i()),
340                    ("cursor", nullable_string()),
341                ],
342                &["query"],
343                true,
344            ),
345        },
346        ToolSpec {
347            name: "graph",
348            description: "The bounded neighborhood around root documents in one call — `{ documents, edges, frontier }` — compiled to an OQX `follow doc.out`/`doc.in` walk.",
349            input_schema: schema(
350                &[
351                    ("roots", strings()),
352                    ("degrees", i()),
353                    (
354                        "direction",
355                        json!({ "type": "string", "enum": ["in", "out", "both"] }),
356                    ),
357                    ("predicate", s()),
358                    ("select", strings()),
359                    ("max_documents", i()),
360                ],
361                &["roots"],
362                true,
363            ),
364        },
365        ToolSpec {
366            name: "text_search",
367            description: "Full-text (FTS5, bm25-ranked) search over block text.",
368            input_schema: schema(&[("q", s()), ("limit", i())], &["q"], true),
369        },
370        ToolSpec {
371            name: "resolve",
372            description: "Resolve a name/title/phrase to the blocks it refers to: ranked `{ id, locator, preview, evidence }` (FTS, fused with the vector ranking when a provider exists).",
373            input_schema: schema(&[("query", s()), ("limit", i())], &["query"], true),
374        },
375        ToolSpec {
376            name: "apply",
377            description: "Apply a changeset of kernel ops (insert/update/move/remove/split/merge) atomically; `dry_run` previews diffs.",
378            input_schema: schema(
379                &[
380                    (
381                        "ops",
382                        json!({ "type": "array", "items": { "type": "object" } }),
383                    ),
384                    ("reason", s()),
385                    ("dry_run", b()),
386                ],
387                &["ops"],
388                true,
389            ),
390        },
391        ToolSpec {
392            name: "blocks_insert",
393            description: "Insert blocks parsed from `markdown` under `to` (a block ref, or a document ref for its top level) at `at` (end|start|{before|after}). Optional `expect.parent_children_hash` is the destination-parent CAS: the parent's CURRENT direct child ids (the document's top-level ids for a document `to`) joined by `,` and sha256-hexed — compute it from docs_read include_ids (`ids` filtered by `parents`) — and the insert fails `stale_expectation` (with `data.current.parent_children_hash`) if the siblings changed under you; `content_hash` has no meaning here (there is no target block) and is not accepted.",
394            input_schema: schema(
395                &[
396                    ("to", s()),
397                    ("markdown", s()),
398                    ("at", at_schema()),
399                    ("expect", parent_expect_schema()),
400                    ("dry_run", b()),
401                ],
402                &["to", "markdown"],
403                true,
404            ),
405        },
406        ToolSpec {
407            name: "blocks_update",
408            description: "Replace a block's markdown and/or set attrs (`checked` folds into attrs) with CAS pinned server-side when `expect` is omitted. Returns `id`, `ids` and the apply result.",
409            input_schema: schema(
410                &[
411                    ("block", s()),
412                    ("markdown", s()),
413                    ("checked", b()),
414                    ("attrs", obj()),
415                    ("expect", expect_schema()),
416                    ("dry_run", b()),
417                ],
418                &["block"],
419                true,
420            ),
421        },
422        ToolSpec {
423            name: "blocks_move",
424            description: "Move blocks under a new parent at a position; `to` is a block ref or the blocks' own document. Optional `expect.parent_children_hash` is the destination-parent CAS (as blocks_insert: the destination parent's CURRENT direct child ids joined by `,`, sha256 hex), checked once for the whole run before anything moves — `stale_expectation` with `data.current.parent_children_hash` if the destination's children changed; `content_hash` is meaningless here and not accepted.",
425            input_schema: schema(
426                &[
427                    ("blocks", strings()),
428                    ("to", s()),
429                    ("at", at_schema()),
430                    ("expect", parent_expect_schema()),
431                    ("dry_run", b()),
432                ],
433                &["blocks", "to"],
434                true,
435            ),
436        },
437        ToolSpec {
438            name: "blocks_remove",
439            description: "Remove blocks (and their subtrees).",
440            input_schema: schema(
441                &[("blocks", strings()), ("dry_run", b())],
442                &["blocks"],
443                true,
444            ),
445        },
446        ToolSpec {
447            name: "blocks_split",
448            description: "Split a block at UTF-8 byte offsets; CAS pinned server-side.",
449            input_schema: schema(
450                &[
451                    ("block", s()),
452                    (
453                        "at",
454                        json!({ "type": "array", "items": { "type": "integer" } }),
455                    ),
456                    ("dry_run", b()),
457                ],
458                &["block", "at"],
459                true,
460            ),
461        },
462        ToolSpec {
463            name: "blocks_merge",
464            description: "Merge adjacent blocks into the first, joined by `separator`.",
465            input_schema: schema(
466                &[("blocks", strings()), ("separator", s()), ("dry_run", b())],
467                &["blocks"],
468                true,
469            ),
470        },
471        ToolSpec {
472            name: "tasks_complete",
473            description: "Check (or uncheck with checked:false) task blocks.",
474            input_schema: schema(
475                &[("blocks", strings()), ("checked", b()), ("dry_run", b())],
476                &["blocks"],
477                true,
478            ),
479        },
480        ToolSpec {
481            name: "node_set",
482            description: "Set one editable property of a projected node (a link's name/value, a task's checked).",
483            input_schema: schema(
484                &[
485                    ("node", s()),
486                    ("prop", s()),
487                    ("value", s()),
488                    ("dry_run", b()),
489                ],
490                &["node", "prop", "value"],
491                true,
492            ),
493        },
494        ToolSpec {
495            name: "sections_append",
496            description: "Append markdown at the end of a heading's section; `heading` is a heading block id or its text (scoped by `doc`/`path`).",
497            input_schema: schema(
498                &[
499                    ("heading", s()),
500                    ("markdown", s()),
501                    ("doc", s()),
502                    ("path", s()),
503                    ("dry_run", b()),
504                ],
505                &["heading", "markdown"],
506                true,
507            ),
508        },
509        ToolSpec {
510            name: "docs_append",
511            description: "Append markdown at the end of a document as new top-level blocks (existing ids preserved).",
512            input_schema: schema(
513                &[("doc", s()), ("path", s()), ("text", s())],
514                &["text"],
515                true,
516            ),
517        },
518        ToolSpec {
519            name: "links_retarget",
520            description: "Rewrite one link destination everywhere it is linked (dry run by default).",
521            input_schema: schema(
522                &[
523                    ("from_target", s()),
524                    ("to_target", s()),
525                    ("path_glob", s()),
526                    ("dry_run", b()),
527                ],
528                &["from_target", "to_target"],
529                true,
530            ),
531        },
532        ToolSpec {
533            name: "links_stale",
534            description: "Dangling internal links (open edges to a `phantom:` target) plus external and total counts; `summary:true` returns counts only.",
535            input_schema: schema(
536                &[("path_glob", s()), ("limit", i()), ("summary", b())],
537                &[],
538                true,
539            ),
540        },
541        ToolSpec {
542            name: "links_repair",
543            description: "Bulk link repair: `repairs` ([{from,to}]) or one `from_target`/`to_target` pair, in one changeset (dry run by default).",
544            input_schema: schema(
545                &[
546                    (
547                        "repairs",
548                        json!({ "type": "array", "items": { "type": "object", "properties": { "from": { "type": "string" }, "to": { "type": "string" } }, "required": ["from", "to"] } }),
549                    ),
550                    ("from_target", s()),
551                    ("to_target", s()),
552                    ("path_glob", s()),
553                    ("dry_run", b()),
554                ],
555                &[],
556                true,
557            ),
558        },
559        ToolSpec {
560            name: "docs_create",
561            description: "Create a document at `path` from `markdown` with optional `frontmatter`; `dry_run` returns the would-be file under `diffs` without writing.",
562            input_schema: schema(
563                &[
564                    ("path", s()),
565                    ("markdown", s()),
566                    ("frontmatter", obj()),
567                    ("dry_run", b()),
568                ],
569                &["path", "markdown"],
570                true,
571            ),
572        },
573        ToolSpec {
574            name: "docs_move",
575            description: "Rename a document to `to_path`, identity preserved; `retarget_inbound` rewrites inbound links; `dry_run` returns the plan (`dangling`, `retargeted`, per-file `diffs`) without renaming.",
576            input_schema: schema(
577                &[
578                    ("doc", s()),
579                    ("to_path", s()),
580                    ("retarget_inbound", b()),
581                    ("dry_run", b()),
582                ],
583                &["doc", "to_path"],
584                true,
585            ),
586        },
587        ToolSpec {
588            name: "docs_delete",
589            description: "Delete a document: tombstone it and remove the file; `dry_run` returns the removal under `diffs` without deleting.",
590            input_schema: schema(&[("doc", s()), ("dry_run", b())], &["doc"], true),
591        },
592        ToolSpec {
593            name: "docs_set_meta",
594            description: "Set and/or unset frontmatter keys, re-ingesting the document; `dry_run` returns the rewritten file under `diffs` without writing.",
595            input_schema: schema(
596                &[
597                    ("doc", s()),
598                    ("set", obj()),
599                    ("unset", strings()),
600                    ("dry_run", b()),
601                ],
602                &["doc"],
603                true,
604            ),
605        },
606        ToolSpec {
607            name: "docs_plan_update",
608            description: "Plan a whole-document update without applying: the opset and a one-line-per-op plan.",
609            input_schema: schema(&[("doc", s()), ("content", s())], &["doc", "content"], true),
610        },
611        ToolSpec {
612            name: "docs_update",
613            description: "Whole-document update with identity preservation: plan then apply (`dry_run` returns the plan only).",
614            input_schema: schema(
615                &[
616                    ("doc", s()),
617                    ("content", s()),
618                    ("reason", s()),
619                    ("dry_run", b()),
620                ],
621                &["doc", "content"],
622                true,
623            ),
624        },
625        ToolSpec {
626            name: "observe",
627            description: "Record `content` as the authoritative bytes at `path` (an observed-origin commit; an echo when unchanged).",
628            input_schema: schema(
629                &[("path", s()), ("content", s())],
630                &["path", "content"],
631                true,
632            ),
633        },
634        ToolSpec {
635            name: "observe_many",
636            description: "Observe several files under one timestamp and one pool sweep.",
637            input_schema: schema(
638                &[(
639                    "files",
640                    json!({ "type": "array", "items": { "type": "object", "properties": { "path": { "type": "string" }, "content": { "type": "string" } }, "required": ["path", "content"] } }),
641                )],
642                &["files"],
643                true,
644            ),
645        },
646        ToolSpec {
647            name: "observe_delete",
648            description: "Record that `path` left the source: an observed, pooled tombstone.",
649            input_schema: schema(&[("path", s())], &["path"], true),
650        },
651        ToolSpec {
652            name: "history_node",
653            description: "A block's biography: the commits that touched it, newest first.",
654            input_schema: schema(&[("id", s()), ("limit", i())], &["id"], false),
655        },
656        ToolSpec {
657            name: "diff",
658            description: "Block-grain diff between two revisions of a document.",
659            input_schema: schema(
660                &[("doc", s()), ("from_rev", s()), ("to_rev", s())],
661                &["doc", "from_rev", "to_rev"],
662                true,
663            ),
664        },
665        ToolSpec {
666            name: "diff_unified",
667            description: "Unified diff (Myers, 3 lines of context) between two revisions (default: the previous and current).",
668            input_schema: schema(
669                &[("doc", s()), ("from_rev", s()), ("to_rev", s())],
670                &["doc"],
671                true,
672            ),
673        },
674        ToolSpec {
675            name: "docs_read_at",
676            description: "The whole document as of a past revision.",
677            input_schema: dp(&[("rev", s())], &["rev"]),
678        },
679        ToolSpec {
680            name: "docs_history",
681            description: "Version history of the docs matching `path_glob` or `doc`, grouped by document.",
682            input_schema: schema(
683                &[
684                    ("path_glob", s()),
685                    ("doc", s()),
686                    ("include_deleted", b()),
687                    ("limit", i()),
688                ],
689                &[],
690                true,
691            ),
692        },
693        ToolSpec {
694            name: "changes_since",
695            description: "The change feed: commit digests after `cursor` (a repo commit seq).",
696            input_schema: schema(
697                &[
698                    ("cursor", i()),
699                    (
700                        "origin",
701                        json!({ "type": "string", "enum": ["api", "observed", "import"] }),
702                    ),
703                    ("limit", i()),
704                ],
705                &[],
706                true,
707            ),
708        },
709        ToolSpec {
710            name: "repos_status",
711            description: "Repo counts, unconverged docs and on-disk drift.",
712            input_schema: schema(&[], &[], true),
713        },
714        ToolSpec {
715            name: "sync_status",
716            description: "Sync state: last commit seq, last checkpoint, convergence.",
717            input_schema: schema(&[], &[], true),
718        },
719        ToolSpec {
720            name: "repos",
721            description: "The repos in this workspace: `{ repos: [{ slug, hasSource }] }`.",
722            input_schema: schema(&[], &[], false),
723        },
724        ToolSpec {
725            name: "version",
726            description: "Which engine and which versions you are talking to: `{ engine: \"typescript\"|\"rust\", version, components: { <package>: <version> }, specs: { oqx, format, reconcile, store, properties, graph, search, mutate, sync, surface, cli }, schema, mcp: { protocol, sdk? }, runtime, commit, built }`. `version` is the serving binary's own; `components` every omgbase package it is built from; `specs` the spec versions it implements (compile-time constants); `schema` the database's `PRAGMA user_version`; `commit`/`built` the build's git revision and RFC 3339 time (null when unknown). No arguments, no repo scope.",
727            input_schema: schema(&[], &[], false),
728        },
729    ]
730}
731
732impl Surface {
733    /// A surface over `store` whose default repo is `default_repo` (an id).
734    /// Mutations derive each repo's root from its `fs` source.
735    #[must_use]
736    pub fn new(
737        store: Store,
738        default_repo: &str,
739        provider: Option<Box<dyn EmbeddingProvider>>,
740    ) -> Self {
741        // Read-path tuning of the surface's own connection; neither pragma
742        // changes a result. The reference runs on better-sqlite3, whose
743        // bundled SQLite is compiled with a 16 MB default page cache
744        // (`SQLITE_DEFAULT_CACHE_SIZE=-16000`); rusqlite's bundled build
745        // keeps the stock 2 MB, so the same root scans and pushed
746        // predicates over a database of tens of MB miss the cache and
747        // `pread` page by page. `temp_store=MEMORY` keeps the sorter of a
748        // `… ORDER BY d.path, b.block_id` scan (tens of thousands of rows)
749        // from spilling its runs to temp files. A failure here only leaves
750        // the connection untuned.
751        let _ = store
752            .conn()
753            .execute_batch("PRAGMA cache_size = -16000; PRAGMA temp_store = MEMORY;");
754        Self {
755            store,
756            default_repo: default_repo.to_owned(),
757            provider,
758            on_mutation: None,
759            clock: Box::new(omgbase_sync::now_ts),
760            writes: WriteTarget::Derived,
761            config: Config::default(),
762            actor: ACTOR.to_owned(),
763            build_info: BuildInfo::default(),
764        }
765    }
766
767    /// Record `actor` on every write instead of [`ACTOR`] (the CLI's
768    /// `human:<user>` / `--actor`; the MCP server keeps the default).
769    #[must_use]
770    pub fn with_actor(mut self, actor: &str) -> Self {
771        self.set_actor(actor);
772        self
773    }
774
775    /// Change the actor of the writes that follow.
776    pub fn set_actor(&mut self, actor: &str) {
777        self.actor = actor.to_owned();
778    }
779
780    /// Route every mutation's file writes through `doc_store` regardless of
781    /// the repo's sources (a runner's in-memory store).
782    #[must_use]
783    pub fn with_doc_store(mut self, doc_store: Box<dyn DocStore>) -> Self {
784        self.writes = WriteTarget::Fixed(doc_store);
785        self
786    }
787
788    /// Stamp commits with `clock()` instead of the wall clock.
789    #[must_use]
790    pub fn with_clock(mut self, clock: impl FnMut() -> String + 'static) -> Self {
791        self.clock = Box::new(clock);
792        self
793    }
794
795    /// Called after every successful write (never after a dry run).
796    #[must_use]
797    pub fn with_mutation_hook(mut self, hook: impl FnMut() + 'static) -> Self {
798        self.on_mutation = Some(Box::new(hook));
799        self
800    }
801
802    /// The matcher thresholds the observe tools pass through.
803    #[must_use]
804    pub fn with_config(mut self, config: Config) -> Self {
805        self.config = config;
806        self
807    }
808
809    /// The serving binary's identity for the `version` tool (§4, 1.4): its
810    /// own version, `spec/cli` version, build commit and time, compiler. A
811    /// library embedding keeps the default (this crate's version, nulls).
812    #[must_use]
813    pub fn with_build_info(mut self, build_info: BuildInfo) -> Self {
814        self.build_info = build_info;
815        self
816    }
817
818    #[must_use]
819    pub fn store(&self) -> &Store {
820        &self.store
821    }
822
823    pub fn store_mut(&mut self) -> &mut Store {
824        &mut self.store
825    }
826
827    #[must_use]
828    pub fn default_repo(&self) -> &str {
829        &self.default_repo
830    }
831
832    /// The catalog.
833    #[must_use]
834    pub fn tools(&self) -> Vec<ToolSpec> {
835        tools()
836    }
837
838    /// Whether `name` is a tool that can commit (a host serializes these
839    /// under the writer lock of `spec/sync` §7; `dry_run` calls still count
840    /// here — only the mutation hook knows a write actually happened).
841    #[must_use]
842    pub fn is_write_tool(name: &str) -> bool {
843        matches!(
844            name,
845            "apply"
846                | "blocks_insert"
847                | "blocks_update"
848                | "blocks_move"
849                | "blocks_remove"
850                | "blocks_split"
851                | "blocks_merge"
852                | "tasks_complete"
853                | "node_set"
854                | "sections_append"
855                | "docs_append"
856                | "links_retarget"
857                | "links_repair"
858                | "docs_create"
859                | "docs_move"
860                | "docs_delete"
861                | "docs_set_meta"
862                | "docs_update"
863                | "observe"
864                | "observe_many"
865                | "observe_delete"
866        )
867    }
868
869    /// Run a tool: its JSON result, or the error envelope.
870    pub fn call(&mut self, name: &str, args: Json) -> ToolOutcome {
871        match self.call_result(name, &args) {
872            Ok(body) => ToolOutcome {
873                body,
874                is_error: false,
875            },
876            Err(e) => ToolOutcome {
877                body: e.to_json(),
878                is_error: true,
879            },
880        }
881    }
882
883    fn now(&mut self) -> String {
884        (self.clock)()
885    }
886
887    fn notify(&mut self) {
888        if let Some(hook) = &mut self.on_mutation {
889            hook();
890        }
891    }
892
893    // ---- repo scoping (§4) ------------------------------------------------------------
894
895    fn repo_rows(&self) -> Result<Vec<RepoRow>> {
896        Ok(omgbase_sync::workspace::list_repos(&self.store)?)
897    }
898
899    /// `(repo id, derived root)` for the `repo` argument; omitted → the default.
900    fn scope(&self, args: &Json) -> Result<(String, Option<String>)> {
901        let rows = self.repo_rows()?;
902        match arg_str(args, "repo") {
903            None => {
904                let root = rows
905                    .iter()
906                    .find(|r| r.repo_id == self.default_repo)
907                    .and_then(|r| r.root_path.clone());
908                Ok((self.default_repo.clone(), root))
909            }
910            Some(slug) => rows
911                .iter()
912                .find(|r| r.slug == slug)
913                .map(|r| (r.repo_id.clone(), r.root_path.clone()))
914                .ok_or_else(|| {
915                    SurfaceError::with_data(
916                        "repo_not_found",
917                        format!("no repo '{slug}' in this workspace"),
918                        json!({ "repo": slug }),
919                    )
920                }),
921        }
922    }
923
924    fn require_root(&self, root: Option<&str>) -> Result<()> {
925        if matches!(self.writes, WriteTarget::Fixed(_)) || root.is_some() {
926            Ok(())
927        } else {
928            Err(SurfaceError::new(
929                "repo_not_found",
930                "repo has no filesystem source; mutation disabled",
931            ))
932        }
933    }
934
935    /// Run `f` with the store and the write target for `root`.
936    fn with_writes<T>(
937        &mut self,
938        root: Option<&str>,
939        f: impl FnOnce(&mut Store, &mut dyn DocStore) -> Result<T>,
940    ) -> Result<T> {
941        self.require_root(root)?;
942        match &mut self.writes {
943            WriteTarget::Fixed(ds) => f(&mut self.store, ds.as_mut()),
944            WriteTarget::Derived => {
945                let mut fs = FsDocStore::new(root.expect("checked by require_root"));
946                f(&mut self.store, &mut fs)
947            }
948        }
949    }
950
951    // ---- ref resolution (§4) ---------------------------------------------------------
952
953    /// The owning doc from `doc` (id or path) / `path` / `block`; `doc_missing` when none.
954    fn resolve_doc_id(
955        &self,
956        repo_id: &str,
957        doc: Option<&str>,
958        path: Option<&str>,
959        block: Option<&str>,
960    ) -> Result<String> {
961        let conn = self.store.conn();
962        let found = if let Some(d) = doc.filter(|d| !d.is_empty()) {
963            read::find_doc_by_ref(conn, repo_id, d)?.map(|i| i.doc_id)
964        } else if let Some(p) = path.filter(|p| !p.is_empty()) {
965            read::find_doc_by_path(conn, repo_id, p)?.map(|i| i.doc_id)
966        } else if let Some(b) = block.filter(|b| !b.is_empty()) {
967            conn.query_row(
968                "SELECT doc_id FROM blocks WHERE block_id = ?1",
969                params![b],
970                |r| r.get::<_, String>(0),
971            )
972            .optional()?
973        } else {
974            None
975        };
976        found.ok_or_else(|| {
977            let mut m = Map::new();
978            if let Some(d) = doc {
979                m.insert("doc".to_owned(), json!(d));
980            }
981            if let Some(p) = path {
982                m.insert("path".to_owned(), json!(p));
983            }
984            if let Some(b) = block {
985                m.insert("block".to_owned(), json!(b));
986            }
987            SurfaceError::with_data(
988                "doc_missing",
989                format!("no document for {}", Json::Object(m.clone())),
990                Json::Object(m),
991            )
992        })
993    }
994
995    fn resolve_doc_from_args(&self, repo_id: &str, args: &Json) -> Result<String> {
996        self.resolve_doc_id(repo_id, arg_str(args, "doc"), arg_str(args, "path"), None)
997    }
998
999    /// `heading` (a heading block id or heading text) → the heading block id.
1000    fn resolve_heading_id(
1001        &self,
1002        repo_id: &str,
1003        heading: &str,
1004        doc: Option<&str>,
1005        path: Option<&str>,
1006    ) -> Result<String> {
1007        let conn = self.store.conn();
1008        let as_block: Option<String> = conn
1009            .query_row(
1010                "SELECT block_id FROM blocks WHERE block_id = ?1 AND type = 'heading' AND deleted_commit IS NULL",
1011                params![heading],
1012                |r| r.get(0),
1013            )
1014            .optional()?;
1015        if let Some(b) = as_block {
1016            return Ok(b);
1017        }
1018        let want_doc = if doc.is_some_and(|d| !d.is_empty()) || path.is_some_and(|p| !p.is_empty())
1019        {
1020            Some(self.resolve_doc_id(repo_id, doc, path, None)?)
1021        } else {
1022            None
1023        };
1024        let needle = if heading.trim_start_matches([' ', '\t']).starts_with('#') {
1025            normalize_visible_text(heading, BlockKind::Heading, 0)
1026        } else {
1027            normalize_text(heading)
1028        };
1029        let rows: Vec<(String, String, String)> = match &want_doc {
1030            Some(d) => {
1031                let mut stmt = conn.prepare(
1032                    "SELECT block_id, doc_id, text FROM blocks WHERE repo_id = ?1 AND type = 'heading' AND doc_id = ?2 AND deleted_commit IS NULL",
1033                )?;
1034                let it = stmt.query_map(params![repo_id, d], |r| {
1035                    Ok((r.get(0)?, r.get(1)?, r.get(2)?))
1036                })?;
1037                it.collect::<std::result::Result<_, _>>()?
1038            }
1039            None => {
1040                let mut stmt = conn.prepare(
1041                    "SELECT block_id, doc_id, text FROM blocks WHERE repo_id = ?1 AND type = 'heading' AND deleted_commit IS NULL",
1042                )?;
1043                let it =
1044                    stmt.query_map(params![repo_id], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?;
1045                it.collect::<std::result::Result<_, _>>()?
1046            }
1047        };
1048        let matches: Vec<&(String, String, String)> = rows
1049            .iter()
1050            .filter(|(_, _, text)| normalize_text(text) == needle)
1051            .collect();
1052        match matches.len() {
1053            0 => {
1054                let mut data = Map::new();
1055                data.insert("heading".to_owned(), json!(heading));
1056                if let Some(d) = want_doc {
1057                    data.insert("doc".to_owned(), json!(d));
1058                }
1059                Err(SurfaceError::with_data(
1060                    "parent_missing",
1061                    format!("no heading matching {}", Json::String(heading.to_owned())),
1062                    Json::Object(data),
1063                ))
1064            }
1065            1 => Ok(matches[0].0.clone()),
1066            n => Err(SurfaceError::with_data(
1067                "ambiguous_heading",
1068                format!(
1069                    "heading {} matches {n} headings; pass its block id or a doc/path scope",
1070                    Json::String(heading.to_owned())
1071                ),
1072                json!({
1073                    "heading": heading,
1074                    "candidates": matches.iter().map(|(b, d, _)| json!({ "block": b, "doc": d })).collect::<Vec<_>>(),
1075                }),
1076            )),
1077        }
1078    }
1079
1080    /// A ref → a live block id (`block_missing` otherwise).
1081    fn resolve_block_ref(&self, repo_id: &str, r: &str) -> Result<String> {
1082        match read::resolve_ref(self.store.conn(), repo_id, r)? {
1083            Some(ResolvedRef::Block { block_id, .. }) => Ok(block_id),
1084            _ => Err(SurfaceError::with_data(
1085                "block_missing",
1086                format!("not a block: {r}"),
1087                json!({ "ref": r }),
1088            )),
1089        }
1090    }
1091
1092    /// A parent ref: a block, or a document ref for its top level.
1093    fn resolve_parent_ref(&self, repo_id: &str, r: &str) -> Result<(Parent, String)> {
1094        match read::resolve_ref(self.store.conn(), repo_id, r)? {
1095            Some(ResolvedRef::Block { doc_id, block_id }) => Ok((Parent::Block(block_id), doc_id)),
1096            Some(ResolvedRef::Document { doc_id }) => Ok((Parent::Doc, doc_id)),
1097            None => Err(SurfaceError::with_data(
1098                "block_missing",
1099                format!("not a block or document: {r}"),
1100                json!({ "ref": r }),
1101            )),
1102        }
1103    }
1104
1105    fn doc_id_of_block(&self, block_id: &str) -> Result<Option<String>> {
1106        Ok(self
1107            .store
1108            .conn()
1109            .query_row(
1110                "SELECT doc_id FROM blocks WHERE block_id = ?1 AND deleted_commit IS NULL",
1111                params![block_id],
1112                |r| r.get(0),
1113            )
1114            .optional()?)
1115    }
1116
1117    /// The live block's raw hash (hex) for CAS pinning; `None` when unknown.
1118    fn pin_hash(&self, block_id: &str) -> Result<Option<String>> {
1119        let h: Option<Vec<u8>> = self
1120            .store
1121            .conn()
1122            .query_row(
1123                "SELECT raw_hash FROM blocks WHERE block_id = ?1 AND deleted_commit IS NULL",
1124                params![block_id],
1125                |r| r.get(0),
1126            )
1127            .optional()?;
1128        Ok(h.map(|h| omgbase_format::hash::hex(&h)))
1129    }
1130
1131    /// The `at` spec with its anchor ref resolved; absent → end.
1132    fn resolve_at(&self, repo_id: &str, at: Option<&Json>) -> Result<At> {
1133        match at {
1134            None | Some(Json::Null) => Ok(At::End),
1135            Some(Json::String(s)) if s == "start" => Ok(At::Start),
1136            Some(Json::String(s)) if s == "end" => Ok(At::End),
1137            Some(Json::Object(o)) => {
1138                if let Some(b) = o.get("before").and_then(Json::as_str) {
1139                    return Ok(At::Before(self.resolve_block_ref(repo_id, b)?));
1140                }
1141                if let Some(a) = o.get("after").and_then(Json::as_str) {
1142                    return Ok(At::After(self.resolve_block_ref(repo_id, a)?));
1143                }
1144                Err(bad_args(
1145                    "`at` must be \"start\", \"end\", {before} or {after}",
1146                ))
1147            }
1148            Some(_) => Err(bad_args(
1149                "`at` must be \"start\", \"end\", {before} or {after}",
1150            )),
1151        }
1152    }
1153
1154    // ---- the apply tail -----------------------------------------------------------------
1155
1156    fn apply_ops(
1157        &mut self,
1158        repo_id: &str,
1159        root: Option<&str>,
1160        ops: Vec<Op>,
1161        reason: &str,
1162        dry_run: bool,
1163    ) -> Result<ApplyResult> {
1164        let ts = self.now();
1165        let req = ApplyRequest {
1166            repo_id: repo_id.to_owned(),
1167            ops,
1168            origin: ApplyOrigin::new(&self.actor, Some(reason)),
1169            dry_run,
1170            set_frontmatter: Vec::new(),
1171        };
1172        let res = self.with_writes(root, |store, ds| Ok(store.apply(&req, ds, &ts)?))?;
1173        if !dry_run {
1174            self.notify();
1175        }
1176        Ok(res)
1177    }
1178
1179    fn doc_ctx(&mut self, repo_id: &str) -> DocOpContext {
1180        DocOpContext {
1181            repo_id: repo_id.to_owned(),
1182            actor: Some(self.actor.clone()),
1183            ts: self.now(),
1184        }
1185    }
1186
1187    // ---- dispatch -------------------------------------------------------------------------
1188
1189    /// Run a tool, returning its result or the error.
1190    #[allow(clippy::too_many_lines)]
1191    pub fn call_result(&mut self, name: &str, args: &Json) -> Result<Json> {
1192        match name {
1193            "docs_outline" => {
1194                let (repo, _) = self.scope(args)?;
1195                let doc_id = self.resolve_doc_from_args(&repo, args)?;
1196                let skeleton = match arg_str(args, "resolution") {
1197                    None | Some("outline") => false,
1198                    Some("skeleton") => true,
1199                    Some(_) => return Err(bad_args("`resolution` must be skeleton or outline")),
1200                };
1201                read::docs_outline(
1202                    &self.store,
1203                    &doc_id,
1204                    skeleton,
1205                    arg_i64(args, "depth")?,
1206                    arg_usize(args, "budget_tokens")?,
1207                )
1208            }
1209            "docs_read" => {
1210                let (repo, _) = self.scope(args)?;
1211                let doc_id = self.resolve_doc_from_args(&repo, args)?;
1212                read::docs_read(
1213                    &self.store,
1214                    &doc_id,
1215                    arg_bool(args, "include_ids")?.unwrap_or(false),
1216                )?
1217                .ok_or_else(|| SurfaceError::new("doc_missing", format!("no document for {args}")))
1218            }
1219            "docs_get_many" => {
1220                let (repo, _) = self.scope(args)?;
1221                let refs = arg_strings(args, "docs")?;
1222                read::docs_read_many(
1223                    &self.store,
1224                    &repo,
1225                    &refs,
1226                    arg_bool(args, "include_ids")?.unwrap_or(false),
1227                    arg_usize(args, "budget_tokens")?,
1228                )
1229            }
1230            "nodes_get" => {
1231                let (repo, _) = self.scope(args)?;
1232                let id = arg_string(args, "id")?;
1233                let doc_id = self.resolve_doc_id(
1234                    &repo,
1235                    arg_str(args, "doc"),
1236                    arg_str(args, "path"),
1237                    Some(&id),
1238                )?;
1239                read::nodes_get(
1240                    &self.store,
1241                    &doc_id,
1242                    &id,
1243                    resolution_arg(args, Resolution::Full)?,
1244                )?
1245                .ok_or_else(|| SurfaceError::new("block_missing", format!("no block {id}")))
1246            }
1247            "nodes_get_many" => {
1248                let (repo, _) = self.scope(args)?;
1249                let ids = arg_strings(args, "ids")?;
1250                let scoped = arg_str(args, "doc").is_some_and(|d| !d.is_empty())
1251                    || arg_str(args, "path").is_some_and(|p| !p.is_empty());
1252                let doc_id = if scoped {
1253                    Some(self.resolve_doc_from_args(&repo, args)?)
1254                } else {
1255                    None
1256                };
1257                read::nodes_get_many(
1258                    &self.store,
1259                    doc_id.as_deref(),
1260                    &ids,
1261                    resolution_arg(args, Resolution::Text)?,
1262                    arg_usize(args, "budget_tokens")?,
1263                )
1264            }
1265            "read_ref" => {
1266                let (repo, _) = self.scope(args)?;
1267                let r = arg_string(args, "ref")?;
1268                let resolved =
1269                    read::resolve_ref(self.store.conn(), &repo, &r)?.ok_or_else(|| {
1270                        SurfaceError::new(
1271                            "doc_missing",
1272                            format!("no document or block for {}", Json::String(r.clone())),
1273                        )
1274                    })?;
1275                match resolved {
1276                    ResolvedRef::Document { doc_id } => {
1277                        let res =
1278                            read::docs_read(&self.store, &doc_id, false)?.ok_or_else(|| {
1279                                SurfaceError::new(
1280                                    "doc_missing",
1281                                    format!("no document for {}", Json::String(r.clone())),
1282                                )
1283                            })?;
1284                        let mut m = Map::new();
1285                        m.insert("kind".to_owned(), json!("document"));
1286                        Ok(merge(m, res))
1287                    }
1288                    ResolvedRef::Block { doc_id, block_id } => {
1289                        let node = read::nodes_get(
1290                            &self.store,
1291                            &doc_id,
1292                            &block_id,
1293                            resolution_arg(args, Resolution::Raw)?,
1294                        )?
1295                        .ok_or_else(|| {
1296                            SurfaceError::new("block_missing", format!("no block {block_id}"))
1297                        })?;
1298                        let mut m = Map::new();
1299                        m.insert("kind".to_owned(), json!("block"));
1300                        Ok(merge(m, node))
1301                    }
1302                }
1303            }
1304            "docs_tree" => {
1305                let (repo, _) = self.scope(args)?;
1306                read::docs_tree(
1307                    &self.store,
1308                    &repo,
1309                    arg_str(args, "path"),
1310                    arg_i64(args, "depth")?,
1311                    arg_i64(args, "limit")?,
1312                    arg_str(args, "cursor"),
1313                    arg_usize(args, "budget_tokens")?,
1314                )
1315            }
1316            "docs_list" => {
1317                let (repo, _) = self.scope(args)?;
1318                read::docs_list(
1319                    &self.store,
1320                    &repo,
1321                    arg_str(args, "path_glob"),
1322                    arg_i64(args, "limit")?,
1323                    arg_str(args, "cursor"),
1324                    arg_usize(args, "budget_tokens")?,
1325                )
1326            }
1327            "query_syntax" => Ok(json!({ "syntax": QUERY_SYNTAX })),
1328            "query" => {
1329                let (repo, _) = self.scope(args)?;
1330                let source = arg_string(args, "query")?;
1331                // A `semantic(...)` query with no provider is `semantic_unavailable`
1332                // here (the runner itself reports `filter_invalid`, §9).
1333                if self.provider.is_none()
1334                    && !crate::query::collect_semantic_phrases(&source).is_empty()
1335                {
1336                    return Err(SurfaceError::new(
1337                        "semantic_unavailable",
1338                        "no embedding provider configured for this server",
1339                    ));
1340                }
1341                let opts = QueryOptions {
1342                    limit: arg_usize(args, "limit")?,
1343                    cursor: arg_str(args, "cursor"),
1344                    provider: self.provider.as_deref(),
1345                    in_memory: false,
1346                };
1347                Ok(query(&self.store, &repo, &source, opts)?.to_json())
1348            }
1349            "graph" => {
1350                let (repo, _) = self.scope(args)?;
1351                let g = GraphArgs {
1352                    roots: arg_strings(args, "roots")?,
1353                    degrees: arg_i64(args, "degrees")?,
1354                    direction: arg_str(args, "direction").map(str::to_owned),
1355                    predicate: arg_str(args, "predicate").map(str::to_owned),
1356                    select: if args.get("select").is_some() {
1357                        arg_strings(args, "select")?
1358                    } else {
1359                        Vec::new()
1360                    },
1361                    max_documents: arg_i64(args, "max_documents")?,
1362                };
1363                if self.provider.is_none()
1364                    && g.select.iter().any(|s| {
1365                        !crate::query::collect_semantic_phrases(&format!("from docs select x: {s}"))
1366                            .is_empty()
1367                    })
1368                {
1369                    return Err(SurfaceError::new(
1370                        "semantic_unavailable",
1371                        "no embedding provider configured for this server",
1372                    ));
1373                }
1374                graph_neighborhood(&self.store, &repo, &g, self.provider.as_deref())
1375            }
1376            "text_search" => {
1377                let (repo, _) = self.scope(args)?;
1378                let q = arg_string(args, "q")?;
1379                let res =
1380                    self.store
1381                        .text_search(&repo, &q, arg_usize(args, "limit")?.unwrap_or(50))?;
1382                Ok(json!({
1383                    "hits": res.hits.iter().map(|h| json!({
1384                        "blockId": h.block_id, "docId": h.doc_id, "path": h.path, "type": h.block_type, "text": h.text, "score": h.score,
1385                    })).collect::<Vec<_>>(),
1386                    "truncated": res.truncated,
1387                }))
1388            }
1389            "resolve" => {
1390                let (repo, _) = self.scope(args)?;
1391                let q = arg_string(args, "query")?;
1392                let vector = match &self.provider {
1393                    Some(p) => Some(QueryVector {
1394                        model: p.model().to_owned(),
1395                        vec: p
1396                            .embed_query(&q)
1397                            .map_err(|e| SurfaceError::new(e.code(), e.to_string()))?,
1398                    }),
1399                    None => None,
1400                };
1401                let hits = self
1402                    .store
1403                    .resolve(&repo, &q, vector, arg_usize(args, "limit")?)?;
1404                Ok(Json::Array(hits.iter().map(|h| json!({
1405                    "id": h.id, "locator": h.locator, "preview": h.preview, "evidence": evidence_json(&h.evidence),
1406                })).collect()))
1407            }
1408            "apply" => {
1409                let (repo, root) = self.scope(args)?;
1410                self.require_root(root.as_deref())?;
1411                let ops = args
1412                    .get("ops")
1413                    .and_then(Json::as_array)
1414                    .ok_or_else(|| bad_args("`ops` must be an array"))?;
1415                let ops: Vec<Op> = ops
1416                    .iter()
1417                    .map(|o| Op::from_json(o).map_err(bad_args))
1418                    .collect::<Result<_>>()?;
1419                let dry = arg_bool(args, "dry_run")?.unwrap_or(false);
1420                let reason = arg_str(args, "reason").map(str::to_owned);
1421                let ts = self.now();
1422                let req = ApplyRequest {
1423                    repo_id: repo.clone(),
1424                    ops,
1425                    origin: ApplyOrigin::new(&self.actor, reason.as_deref()),
1426                    dry_run: dry,
1427                    set_frontmatter: Vec::new(),
1428                };
1429                let res =
1430                    self.with_writes(root.as_deref(), |store, ds| Ok(store.apply(&req, ds, &ts)?))?;
1431                if !dry {
1432                    self.notify();
1433                }
1434                Ok(apply_json(&res))
1435            }
1436            "blocks_insert" => {
1437                let (repo, root) = self.scope(args)?;
1438                self.require_root(root.as_deref())?;
1439                let (parent, doc_id) = self.resolve_parent_ref(&repo, &arg_string(args, "to")?)?;
1440                let at = self.resolve_at(&repo, args.get("at"))?;
1441                let doc = if parent == Parent::Doc {
1442                    Some(doc_id)
1443                } else {
1444                    None
1445                };
1446                let ops = vec![Op::Insert {
1447                    doc,
1448                    to: To { parent, at },
1449                    markdown: arg_string(args, "markdown")?,
1450                    expect: arg_parent_expect(args)?,
1451                }];
1452                Ok(apply_json(&self.apply_ops(
1453                    &repo,
1454                    root.as_deref(),
1455                    ops,
1456                    "blocks_insert",
1457                    arg_bool(args, "dry_run")?.unwrap_or(false),
1458                )?))
1459            }
1460            "blocks_update" => {
1461                let (repo, root) = self.scope(args)?;
1462                self.require_root(root.as_deref())?;
1463                let block = self.resolve_block_ref(&repo, &arg_string(args, "block")?)?;
1464                let expect = match arg_expect(args)? {
1465                    Some(e) => Some(e),
1466                    None => self.pin_hash(&block)?.map(Expect::content),
1467                };
1468                let checked = arg_bool(args, "checked")?;
1469                let extra = arg_object(args, "attrs")?;
1470                let attrs = if checked.is_some() || extra.is_some() {
1471                    let mut a = Map::new();
1472                    if let Some(c) = checked {
1473                        a.insert("checked".to_owned(), json!(c));
1474                    }
1475                    if let Some(x) = extra {
1476                        for (k, v) in x {
1477                            a.insert(k.clone(), v.clone());
1478                        }
1479                    }
1480                    Some(a)
1481                } else {
1482                    None
1483                };
1484                let ops = vec![Op::Update {
1485                    block: block.clone(),
1486                    markdown: arg_str(args, "markdown").map(str::to_owned),
1487                    attrs,
1488                    expect,
1489                    trivia: None,
1490                    child_ids: None,
1491                }];
1492                let dry = arg_bool(args, "dry_run")?.unwrap_or(false);
1493                let res = self.apply_ops(&repo, root.as_deref(), ops, "blocks_update", dry)?;
1494                let ids: Vec<String> = res
1495                    .results
1496                    .first()
1497                    .map_or_else(|| vec![block.clone()], |r| r.ids.clone());
1498                let mut m = Map::new();
1499                m.insert(
1500                    "id".to_owned(),
1501                    json!(ids.first().cloned().unwrap_or(block)),
1502                );
1503                m.insert("ids".to_owned(), json!(ids));
1504                Ok(merge(m, apply_json(&res)))
1505            }
1506            "blocks_move" => {
1507                let (repo, root) = self.scope(args)?;
1508                self.require_root(root.as_deref())?;
1509                let blocks: Vec<String> = arg_strings(args, "blocks")?
1510                    .iter()
1511                    .map(|b| self.resolve_block_ref(&repo, b))
1512                    .collect::<Result<_>>()?;
1513                let to_ref = arg_string(args, "to")?;
1514                let (parent, doc_id) = self.resolve_parent_ref(&repo, &to_ref)?;
1515                if parent == Parent::Doc {
1516                    if let Some(first) = blocks.first() {
1517                        if self.doc_id_of_block(first)? != Some(doc_id) {
1518                            return Err(SurfaceError::with_data(
1519                                "target_missing",
1520                                format!(
1521                                    "blocks_move cannot target another document's root ({to_ref}); anchor on a block in that document with at.before/at.after"
1522                                ),
1523                                json!({ "to": to_ref }),
1524                            ));
1525                        }
1526                    }
1527                }
1528                let at = self.resolve_at(&repo, args.get("at"))?;
1529                let ops = vec![Op::Move {
1530                    blocks,
1531                    to: To { parent, at },
1532                    expect: arg_parent_expect(args)?,
1533                }];
1534                Ok(apply_json(&self.apply_ops(
1535                    &repo,
1536                    root.as_deref(),
1537                    ops,
1538                    "blocks_move",
1539                    arg_bool(args, "dry_run")?.unwrap_or(false),
1540                )?))
1541            }
1542            "blocks_remove" => {
1543                let (repo, root) = self.scope(args)?;
1544                self.require_root(root.as_deref())?;
1545                let blocks: Vec<String> = arg_strings(args, "blocks")?
1546                    .iter()
1547                    .map(|b| self.resolve_block_ref(&repo, b))
1548                    .collect::<Result<_>>()?;
1549                let ops = vec![Op::Remove {
1550                    blocks,
1551                    expect: None,
1552                }];
1553                Ok(apply_json(&self.apply_ops(
1554                    &repo,
1555                    root.as_deref(),
1556                    ops,
1557                    "blocks_remove",
1558                    arg_bool(args, "dry_run")?.unwrap_or(false),
1559                )?))
1560            }
1561            "blocks_split" => {
1562                let (repo, root) = self.scope(args)?;
1563                self.require_root(root.as_deref())?;
1564                let block = self.resolve_block_ref(&repo, &arg_string(args, "block")?)?;
1565                let at: Vec<usize> = args
1566                    .get("at")
1567                    .and_then(Json::as_array)
1568                    .ok_or_else(|| bad_args("`at` must be an array of byte offsets"))?
1569                    .iter()
1570                    .map(|v| {
1571                        v.as_u64()
1572                            .map(|n| usize::try_from(n).unwrap_or(usize::MAX))
1573                            .ok_or_else(|| bad_args("`at` must be an array of byte offsets"))
1574                    })
1575                    .collect::<Result<_>>()?;
1576                // §9: an empty hash when the block has no live row.
1577                let expect = Some(Expect::content(self.pin_hash(&block)?.unwrap_or_default()));
1578                let ops = vec![Op::Split { block, at, expect }];
1579                Ok(apply_json(&self.apply_ops(
1580                    &repo,
1581                    root.as_deref(),
1582                    ops,
1583                    "blocks_split",
1584                    arg_bool(args, "dry_run")?.unwrap_or(false),
1585                )?))
1586            }
1587            "blocks_merge" => {
1588                let (repo, root) = self.scope(args)?;
1589                self.require_root(root.as_deref())?;
1590                let blocks: Vec<String> = arg_strings(args, "blocks")?
1591                    .iter()
1592                    .map(|b| self.resolve_block_ref(&repo, b))
1593                    .collect::<Result<_>>()?;
1594                let ops = vec![Op::Merge {
1595                    blocks,
1596                    separator: arg_str(args, "separator").map(str::to_owned),
1597                    expect: None,
1598                }];
1599                Ok(apply_json(&self.apply_ops(
1600                    &repo,
1601                    root.as_deref(),
1602                    ops,
1603                    "blocks_merge",
1604                    arg_bool(args, "dry_run")?.unwrap_or(false),
1605                )?))
1606            }
1607            "tasks_complete" => {
1608                let (repo, root) = self.scope(args)?;
1609                self.require_root(root.as_deref())?;
1610                let blocks: Vec<String> = arg_strings(args, "blocks")?
1611                    .iter()
1612                    .map(|b| self.resolve_block_ref(&repo, b))
1613                    .collect::<Result<_>>()?;
1614                let ops = if arg_bool(args, "checked")?.unwrap_or(true) {
1615                    self.store.tasks_complete(&blocks)?
1616                } else {
1617                    let mut ops = Vec::with_capacity(blocks.len());
1618                    for b in &blocks {
1619                        let mut a = Map::new();
1620                        a.insert("checked".to_owned(), json!(false));
1621                        ops.push(Op::Update {
1622                            block: b.clone(),
1623                            markdown: None,
1624                            attrs: Some(a),
1625                            expect: self.pin_hash(b)?.map(Expect::content),
1626                            trivia: None,
1627                            child_ids: None,
1628                        });
1629                    }
1630                    ops
1631                };
1632                Ok(apply_json(&self.apply_ops(
1633                    &repo,
1634                    root.as_deref(),
1635                    ops,
1636                    "tasks_complete",
1637                    arg_bool(args, "dry_run")?.unwrap_or(false),
1638                )?))
1639            }
1640            "node_set" => {
1641                let (repo, root) = self.scope(args)?;
1642                self.require_root(root.as_deref())?;
1643                let ops = self.store.node_set(
1644                    &arg_string(args, "node")?,
1645                    &arg_string(args, "prop")?,
1646                    &arg_string(args, "value")?,
1647                )?;
1648                Ok(apply_json(&self.apply_ops(
1649                    &repo,
1650                    root.as_deref(),
1651                    ops,
1652                    "node_set",
1653                    arg_bool(args, "dry_run")?.unwrap_or(false),
1654                )?))
1655            }
1656            "sections_append" => {
1657                let (repo, root) = self.scope(args)?;
1658                self.require_root(root.as_deref())?;
1659                let heading = self.resolve_heading_id(
1660                    &repo,
1661                    &arg_string(args, "heading")?,
1662                    arg_str(args, "doc"),
1663                    arg_str(args, "path"),
1664                )?;
1665                let ops = Store::sections_append(&heading, &arg_string(args, "markdown")?);
1666                Ok(apply_json(&self.apply_ops(
1667                    &repo,
1668                    root.as_deref(),
1669                    ops,
1670                    "sections_append",
1671                    arg_bool(args, "dry_run")?.unwrap_or(false),
1672                )?))
1673            }
1674            "docs_append" => {
1675                let (repo, root) = self.scope(args)?;
1676                self.require_root(root.as_deref())?;
1677                let doc_id = self.resolve_doc_from_args(&repo, args)?;
1678                let ops = Store::docs_append(&doc_id, &arg_string(args, "text")?);
1679                Ok(apply_json(&self.apply_ops(
1680                    &repo,
1681                    root.as_deref(),
1682                    ops,
1683                    "docs_append",
1684                    false,
1685                )?))
1686            }
1687            "links_retarget" | "links_repair" => {
1688                let (repo, root) = self.scope(args)?;
1689                self.require_root(root.as_deref())?;
1690                let repairs: Vec<omgbase_store::LinkRepair> = if name == "links_retarget" {
1691                    vec![omgbase_store::LinkRepair {
1692                        from: arg_string(args, "from_target")?,
1693                        to: arg_string(args, "to_target")?,
1694                    }]
1695                } else if let Some(list) = args.get("repairs").and_then(Json::as_array) {
1696                    list.iter()
1697                        .map(|r| {
1698                            Ok(omgbase_store::LinkRepair {
1699                                from: arg_string(r, "from")?,
1700                                to: arg_string(r, "to")?,
1701                            })
1702                        })
1703                        .collect::<Result<_>>()?
1704                } else if let (Some(f), Some(t)) =
1705                    (arg_str(args, "from_target"), arg_str(args, "to_target"))
1706                {
1707                    vec![omgbase_store::LinkRepair {
1708                        from: f.to_owned(),
1709                        to: t.to_owned(),
1710                    }]
1711                } else {
1712                    Vec::new()
1713                };
1714                if repairs.is_empty() {
1715                    return Err(SurfaceError::new(
1716                        "target_missing",
1717                        "links_repair requires `repairs` (array of {from,to}) or a `from_target`+`to_target` pair",
1718                    ));
1719                }
1720                let plan = self.store.links_repair(
1721                    &repo,
1722                    &repairs,
1723                    arg_str(args, "path_glob").filter(|g| !g.is_empty()),
1724                )?;
1725                let dry = arg_bool(args, "dry_run")?.unwrap_or(true);
1726                let res = self.apply_ops(&repo, root.as_deref(), plan.ops.clone(), name, dry)?;
1727                let mut m = Map::new();
1728                m.insert(
1729                    "hits".to_owned(),
1730                    Json::Array(plan.hits.iter().map(|h| json!({ "block": h.block, "path": h.path, "oldRaw": h.old_raw, "newRaw": h.new_raw })).collect()),
1731                );
1732                m.insert(
1733                    "pairs".to_owned(),
1734                    Json::Array(
1735                        plan.pairs
1736                            .iter()
1737                            .map(|p| json!({ "from": p.from, "to": p.to, "hits": p.hits }))
1738                            .collect(),
1739                    ),
1740                );
1741                m.insert("applied".to_owned(), json!(!dry));
1742                Ok(merge(m, apply_json(&res)))
1743            }
1744            "links_stale" => {
1745                let (repo, _) = self.scope(args)?;
1746                let glob = arg_str(args, "path_glob").filter(|g| !g.is_empty());
1747                if arg_bool(args, "summary")?.unwrap_or(false) {
1748                    return links::links_stale_summary(&self.store, &repo, glob);
1749                }
1750                links::links_stale(&self.store, &repo, glob, arg_i64(args, "limit")?)
1751            }
1752            "docs_create" => {
1753                let (repo, root) = self.scope(args)?;
1754                let ctx = self.doc_ctx(&repo);
1755                let path = arg_string(args, "path")?;
1756                let markdown = arg_string(args, "markdown")?;
1757                let fm = arg_object(args, "frontmatter")?.cloned();
1758                let dry = arg_bool(args, "dry_run")?.unwrap_or(false);
1759                let res = self.with_writes(root.as_deref(), |store, ds| {
1760                    Ok(if dry {
1761                        store
1762                            .dry_run()
1763                            .docs_create(&ctx, ds, &path, &markdown, fm.as_ref())?
1764                    } else {
1765                        store.docs_create(&ctx, ds, &path, &markdown, fm.as_ref())?
1766                    })
1767                })?;
1768                if !dry {
1769                    self.notify();
1770                }
1771                Ok(doc_op_json(&res))
1772            }
1773            "docs_move" => {
1774                let (repo, root) = self.scope(args)?;
1775                let ctx = self.doc_ctx(&repo);
1776                let doc = arg_string(args, "doc")?;
1777                let to = arg_string(args, "to_path")?;
1778                let retarget = arg_bool(args, "retarget_inbound")?.unwrap_or(false);
1779                let dry = arg_bool(args, "dry_run")?.unwrap_or(false);
1780                let res = self.with_writes(root.as_deref(), |store, ds| {
1781                    Ok(if dry {
1782                        store.dry_run().docs_move(&ctx, ds, &doc, &to, retarget)?
1783                    } else {
1784                        store.docs_move(&ctx, ds, &doc, &to, retarget)?
1785                    })
1786                })?;
1787                if !dry {
1788                    self.notify();
1789                }
1790                // `{ docId, path, committed, diffs?, dangling, retargeted }` — the
1791                // reference spreads the planned/moved record before the link report.
1792                let mut m = Map::new();
1793                m.insert("docId".to_owned(), json!(res.doc_id));
1794                m.insert("path".to_owned(), json!(res.path));
1795                m.insert("committed".to_owned(), json!(res.committed));
1796                if let Some(diffs) = &res.diffs {
1797                    m.insert("diffs".to_owned(), diffs_json(diffs));
1798                }
1799                m.insert(
1800                    "dangling".to_owned(),
1801                    Json::Array(
1802                        res.dangling
1803                            .iter()
1804                            .map(omgbase_store::InboundLink::to_json)
1805                            .collect(),
1806                    ),
1807                );
1808                m.insert(
1809                    "retargeted".to_owned(),
1810                    json!(
1811                        res.retargeted
1812                            .as_ref()
1813                            .map(|r| json!({ "blocks": r.blocks, "docs": r.docs }))
1814                    ),
1815                );
1816                Ok(Json::Object(m))
1817            }
1818            "docs_delete" => {
1819                let (repo, root) = self.scope(args)?;
1820                let ctx = self.doc_ctx(&repo);
1821                let doc = arg_string(args, "doc")?;
1822                let dry = arg_bool(args, "dry_run")?.unwrap_or(false);
1823                let res = self.with_writes(root.as_deref(), |store, ds| {
1824                    Ok(if dry {
1825                        store.dry_run().docs_delete(&ctx, ds, &doc)?
1826                    } else {
1827                        store.docs_delete(&ctx, ds, &doc)?
1828                    })
1829                })?;
1830                if !dry {
1831                    self.notify();
1832                }
1833                Ok(doc_op_json(&res))
1834            }
1835            "docs_set_meta" => {
1836                let (repo, root) = self.scope(args)?;
1837                let ctx = self.doc_ctx(&repo);
1838                let doc = arg_string(args, "doc")?;
1839                let set = arg_object(args, "set")?.cloned();
1840                let unset = if args.get("unset").is_some() {
1841                    arg_strings(args, "unset")?
1842                } else {
1843                    Vec::new()
1844                };
1845                let dry = arg_bool(args, "dry_run")?.unwrap_or(false);
1846                let res = self.with_writes(root.as_deref(), |store, ds| {
1847                    Ok(if dry {
1848                        store
1849                            .dry_run()
1850                            .docs_set_meta(&ctx, ds, &doc, set.as_ref(), &unset)?
1851                    } else {
1852                        store.docs_set_meta(&ctx, ds, &doc, set.as_ref(), &unset)?
1853                    })
1854                })?;
1855                if !dry {
1856                    self.notify();
1857                }
1858                Ok(doc_op_json(&res))
1859            }
1860            "docs_plan_update" => {
1861                let (repo, root) = self.scope(args)?;
1862                self.require_root(root.as_deref())?;
1863                let doc = arg_string(args, "doc")?;
1864                let content = arg_string(args, "content")?;
1865                let opset = self
1866                    .store
1867                    .plan_update(&repo, &doc, &content, &self.config.clone())?;
1868                Ok(json!({ "opset": opset_json(&opset), "plan": render_opset_plan(&opset) }))
1869            }
1870            "docs_update" => {
1871                let (repo, root) = self.scope(args)?;
1872                let doc = arg_string(args, "doc")?;
1873                let content = arg_string(args, "content")?;
1874                let dry = arg_bool(args, "dry_run")?.unwrap_or(false);
1875                let reason = arg_str(args, "reason").map(str::to_owned);
1876                let origin = ApplyOrigin::new(&self.actor, reason.as_deref());
1877                let config = self.config.clone();
1878                let ts = self.now();
1879                let (opset, result) = self.with_writes(root.as_deref(), |store, ds| {
1880                    Ok(store.docs_update(&repo, &doc, &content, &config, &origin, dry, ds, &ts)?)
1881                })?;
1882                if !dry {
1883                    self.notify();
1884                }
1885                Ok(json!({
1886                    "opset": opset_json(&opset),
1887                    "plan": render_opset_plan(&opset),
1888                    "result": result.map(|r| apply_json(&r)),
1889                }))
1890            }
1891            "observe" => {
1892                let (repo, _) = self.scope(args)?;
1893                let path = arg_string(args, "path")?;
1894                let content = arg_string(args, "content")?;
1895                let ts = self.now();
1896                let config = self.config.clone();
1897                let out = self
1898                    .store
1899                    .observe_one(&repo, &path, &content, &ts, &config)?;
1900                self.store.sweep_pool(&ts)?;
1901                self.notify();
1902                Ok(observe_json(&out))
1903            }
1904            "observe_many" => {
1905                let (repo, _) = self.scope(args)?;
1906                let files = args
1907                    .get("files")
1908                    .and_then(Json::as_array)
1909                    .ok_or_else(|| bad_args("`files` must be an array of {path, content}"))?;
1910                let items: Vec<omgbase_store::BatchItem> = files
1911                    .iter()
1912                    .map(|f| {
1913                        Ok(omgbase_store::BatchItem::observed(
1914                            &arg_string(f, "path")?,
1915                            &arg_string(f, "content")?,
1916                        ))
1917                    })
1918                    .collect::<Result<_>>()?;
1919                let ts = self.now();
1920                let config = self.config.clone();
1921                let outcomes = self.store.observe_batch(&repo, &items, &ts, &config)?;
1922                self.store.sweep_pool(&ts)?;
1923                self.notify();
1924                let mut out = Vec::with_capacity(outcomes.len());
1925                for o in &outcomes {
1926                    match o.as_observed() {
1927                        Some(obs) => out.push(observe_json(obs)),
1928                        None => {
1929                            return Err(SurfaceError::other(format!(
1930                                "observe_many: unexpected outcome for {}",
1931                                o.path()
1932                            )));
1933                        }
1934                    }
1935                }
1936                Ok(Json::Array(out))
1937            }
1938            "observe_delete" => {
1939                let (repo, _) = self.scope(args)?;
1940                let path = arg_string(args, "path")?;
1941                let ts = self.now();
1942                let out = self.store.observe_delete(&repo, &path, &ts)?;
1943                self.notify();
1944                Ok(json!({ "docId": out.doc_id, "path": out.path, "deleted": out.deleted() }))
1945            }
1946            "history_node" => history::history_node(
1947                &self.store,
1948                &arg_string(args, "id")?,
1949                arg_i64(args, "limit")?,
1950            ),
1951            "diff" => {
1952                let (repo, _) = self.scope(args)?;
1953                let doc_id = self.resolve_doc_id(&repo, arg_str(args, "doc"), None, None)?;
1954                history::diff_blocks(
1955                    &self.store,
1956                    &doc_id,
1957                    &arg_string(args, "from_rev")?,
1958                    &arg_string(args, "to_rev")?,
1959                )
1960            }
1961            "diff_unified" => {
1962                let (repo, _) = self.scope(args)?;
1963                let doc_ref = arg_string(args, "doc")?;
1964                let doc_id = self.resolve_doc_id(&repo, Some(&doc_ref), None, None)?;
1965                let revs = history::recent_revs(self.store.conn(), &doc_id)?;
1966                let to_rev = arg_str(args, "to_rev")
1967                    .map(str::to_owned)
1968                    .or_else(|| revs.first().cloned());
1969                let from_rev = arg_str(args, "from_rev")
1970                    .map(str::to_owned)
1971                    .or_else(|| revs.get(1).cloned())
1972                    .or_else(|| revs.first().cloned());
1973                let (Some(from), Some(to)) = (from_rev, to_rev) else {
1974                    return Err(SurfaceError::new(
1975                        "target_missing",
1976                        format!("no revisions to diff for {}", Json::String(doc_ref)),
1977                    ));
1978                };
1979                let path: Option<String> = self
1980                    .store
1981                    .conn()
1982                    .query_row(
1983                        "SELECT path FROM docs WHERE doc_id = ?1",
1984                        params![doc_id],
1985                        |r| r.get(0),
1986                    )
1987                    .optional()?;
1988                let diff = history::diff_unified_text(&self.store, &doc_id, &from, &to)?;
1989                Ok(
1990                    json!({ "doc": doc_id, "path": path.unwrap_or_default(), "from": from, "to": to, "diff": diff }),
1991                )
1992            }
1993            "docs_read_at" => {
1994                let (repo, _) = self.scope(args)?;
1995                let doc_id = self.resolve_doc_from_args(&repo, args)?;
1996                let rev = arg_string(args, "rev")?;
1997                read::docs_read_at(&self.store, &doc_id, &rev)?.ok_or_else(|| {
1998                    SurfaceError::with_data(
1999                        "target_missing",
2000                        format!(
2001                            "no revision {} for document {doc_id}",
2002                            Json::String(rev.clone())
2003                        ),
2004                        json!({ "doc": doc_id, "rev": rev }),
2005                    )
2006                })
2007            }
2008            "docs_history" => {
2009                let (repo, _) = self.scope(args)?;
2010                let glob = arg_str(args, "path_glob").filter(|g| !g.is_empty());
2011                let doc = arg_str(args, "doc").filter(|d| !d.is_empty());
2012                if glob.is_none() && doc.is_none() {
2013                    return Err(SurfaceError::new(
2014                        "target_missing",
2015                        "docs_history requires one of path_glob or doc",
2016                    ));
2017                }
2018                let include_deleted = arg_bool(args, "include_deleted")?.unwrap_or(false);
2019                if let Some(d) = doc {
2020                    if history::resolve_doc_row(self.store.conn(), &repo, d, include_deleted)?
2021                        .is_none()
2022                    {
2023                        return Err(SurfaceError::new(
2024                            "doc_missing",
2025                            format!("no document for {}", Json::String(d.to_owned())),
2026                        ));
2027                    }
2028                }
2029                history::docs_history(
2030                    &self.store,
2031                    &repo,
2032                    glob,
2033                    doc,
2034                    include_deleted,
2035                    arg_i64(args, "limit")?,
2036                )
2037            }
2038            "changes_since" => {
2039                let (repo, _) = self.scope(args)?;
2040                let page = self.store.changes_since(
2041                    &repo,
2042                    arg_i64(args, "cursor")?.unwrap_or(0),
2043                    arg_usize(args, "limit")?.unwrap_or(50),
2044                    arg_str(args, "origin").filter(|o| !o.is_empty()),
2045                )?;
2046                Ok(page.to_json())
2047            }
2048            "repos_status" => {
2049                let (repo, root) = self.scope(args)?;
2050                let fs = RealFileSystem;
2051                let disk = root
2052                    .as_deref()
2053                    .map(|r| (&fs as &dyn omgbase_sync::FileSystem, Path::new(r)));
2054                Ok(omgbase_sync::repos_status(&self.store, &repo, disk)?.to_json())
2055            }
2056            "sync_status" => {
2057                let (repo, root) = self.scope(args)?;
2058                let fs = RealFileSystem;
2059                let disk = root
2060                    .as_deref()
2061                    .map(|r| (&fs as &dyn omgbase_sync::FileSystem, Path::new(r)));
2062                Ok(omgbase_sync::sync_status(&self.store, &repo, disk)?.to_json())
2063            }
2064            "repos" => {
2065                let rows = self.repo_rows()?;
2066                Ok(json!({
2067                    "repos": rows.iter().map(|r| json!({ "slug": r.slug, "hasSource": r.root_path.is_some() })).collect::<Vec<_>>(),
2068                }))
2069            }
2070            "version" => version_info(Some(&self.store), &self.build_info),
2071            other => Err(SurfaceError::other(format!("unknown tool {other}"))),
2072        }
2073    }
2074}
2075
2076/// `spec/mutate` §4's result on the wire: `{ results: [{ ids, removed?,
2077/// mergedInto? }], revisions, diffs?, committed }` (camelCase, as the
2078/// reference).
2079#[must_use]
2080pub fn apply_json(res: &ApplyResult) -> Json {
2081    let mut m = Map::new();
2082    m.insert(
2083        "results".to_owned(),
2084        Json::Array(
2085            res.results
2086                .iter()
2087                .map(|r| {
2088                    let mut o = Map::new();
2089                    o.insert("ids".to_owned(), json!(r.ids));
2090                    if let Some(rm) = &r.removed {
2091                        o.insert("removed".to_owned(), json!(rm));
2092                    }
2093                    if let Some(mi) = &r.merged_into {
2094                        o.insert("mergedInto".to_owned(), json!(mi));
2095                    }
2096                    Json::Object(o)
2097                })
2098                .collect(),
2099        ),
2100    );
2101    m.insert(
2102        "revisions".to_owned(),
2103        Json::Array(
2104            res.revisions
2105                .iter()
2106                .map(|r| json!({ "doc": r.doc, "path": r.path }))
2107                .collect(),
2108        ),
2109    );
2110    if let Some(diffs) = &res.diffs {
2111        let mut d = Map::new();
2112        for (path, diff) in diffs {
2113            d.insert(
2114                path.clone(),
2115                json!({ "before": diff.before, "after": diff.after }),
2116            );
2117        }
2118        m.insert("diffs".to_owned(), Json::Object(d));
2119    }
2120    m.insert("committed".to_owned(), json!(res.committed));
2121    Json::Object(m)
2122}
2123
2124/// A document operation's result: `{ docId, path, committed }`.
2125/// `{ docId, path, committed, diffs? }` — `diffs` only on a dry run (1.3).
2126fn doc_op_json(res: &omgbase_store::DocOpResult) -> Json {
2127    let mut m = Map::new();
2128    m.insert("docId".to_owned(), json!(res.doc_id));
2129    m.insert("path".to_owned(), json!(res.path));
2130    m.insert("committed".to_owned(), json!(res.committed));
2131    if let Some(diffs) = &res.diffs {
2132        m.insert("diffs".to_owned(), diffs_json(diffs));
2133    }
2134    Json::Object(m)
2135}
2136
2137/// `spec/mutate` §7's opset on the wire (camelCase precondition keys and
2138/// `matcherV`, as the reference).
2139#[must_use]
2140pub fn opset_json(opset: &Opset) -> Json {
2141    let mut m = Map::new();
2142    m.insert("version".to_owned(), json!(1));
2143    m.insert("kind".to_owned(), json!("doc_update"));
2144    m.insert(
2145        "target".to_owned(),
2146        json!({ "doc": opset.target_doc, "path": opset.target_path }),
2147    );
2148    m.insert(
2149        "precondition".to_owned(),
2150        json!({
2151            "doc": opset.precondition.doc,
2152            "path": opset.precondition.path,
2153            "baseRevision": opset.precondition.base_revision,
2154            "baseContentHash": opset.precondition.base_content_hash,
2155        }),
2156    );
2157    m.insert("matcherV".to_owned(), json!(opset.matcher_v));
2158    m.insert(
2159        "ops".to_owned(),
2160        Json::Array(
2161            opset
2162                .ops
2163                .iter()
2164                .map(|p| {
2165                    let mut o = Map::new();
2166                    // The kernel's JSON spells the carried ids `child_ids`
2167                    // (the `spec/mutate` fixture form); the wire is camelCase
2168                    // like every other key here (`spec/surface` §9;
2169                    // `Op::from_json` accepts both on the way back in).
2170                    let mut op = p.op.to_json();
2171                    if let Some(m) = op.as_object_mut()
2172                        && let Some(c) = m.remove("child_ids")
2173                    {
2174                        m.insert("childIds".to_owned(), c);
2175                    }
2176                    o.insert("op".to_owned(), op);
2177                    o.insert("disposition".to_owned(), json!(p.disposition.as_str()));
2178                    o.insert("blocks".to_owned(), json!(p.blocks));
2179                    o.insert("confidence".to_owned(), json!(p.confidence));
2180                    o.insert("reason".to_owned(), json!(p.reason));
2181                    if let Some(d) = &p.detail {
2182                        o.insert("detail".to_owned(), d.clone());
2183                    }
2184                    Json::Object(o)
2185                })
2186                .collect(),
2187        ),
2188    );
2189    if let Some(fm) = &opset.frontmatter {
2190        m.insert("frontmatter".to_owned(), json!({ "raw": fm }));
2191    }
2192    m.insert("summary".to_owned(), opset.summary.to_json());
2193    m.insert("converges".to_owned(), json!(opset.converges));
2194    m.insert("diagnostics".to_owned(), json!(opset.diagnostics));
2195    Json::Object(m)
2196}
2197
2198/// `spec/search` §4's evidence on the wire: `{ rrf, boosts, ftsRank?,
2199/// vectorRank?, cosine? }`.
2200fn evidence_json(e: &omgbase_store::Evidence) -> Json {
2201    let mut m = Map::new();
2202    m.insert("rrf".to_owned(), json!(e.rrf));
2203    m.insert("boosts".to_owned(), e.boosts.to_json());
2204    if let Some(r) = e.fts_rank {
2205        m.insert("ftsRank".to_owned(), json!(r));
2206    }
2207    if let Some(r) = e.vector_rank {
2208        m.insert("vectorRank".to_owned(), json!(r));
2209        if let Some(c) = e.cosine {
2210            m.insert("cosine".to_owned(), json!(c));
2211        }
2212    }
2213    Json::Object(m)
2214}
2215
2216/// The public `observe` result: `{ docId, path, rev, commitId, converged,
2217/// echo, conflicted, dispositions: [{ kind, count }] }`.
2218fn observe_json(o: &omgbase_store::ObserveOutcome) -> Json {
2219    json!({
2220        "docId": o.doc_id,
2221        "path": o.path,
2222        "rev": o.rev,
2223        "commitId": o.commit_id,
2224        "converged": o.converged,
2225        "echo": o.echo,
2226        "conflicted": o.conflicted,
2227        "dispositions": o.dispositions.iter().map(|(k, n)| json!({ "kind": k, "count": n })).collect::<Vec<_>>(),
2228    })
2229}
2230
2231fn verb(d: omgbase_store::mutate_kernel::PlanDisposition) -> &'static str {
2232    use omgbase_store::mutate_kernel::PlanDisposition as D;
2233    match d {
2234        D::Same => "KEEP  ",
2235        D::Edited | D::EditedMoved => "UPDATE",
2236        D::Moved => "MOVE  ",
2237        D::Inserted => "INSERT",
2238        D::Deleted => "REMOVE",
2239        D::SplitFrom => "SPLIT ",
2240        D::MergedInto => "MERGE ",
2241        D::CopiedFrom => "COPY  ",
2242        D::Resurrected => "RESURR",
2243        D::BulkRewrite => "REWRITE",
2244        D::Retiled => "RETILE",
2245    }
2246}
2247
2248/// The one-line-per-op plan text of `docs_plan_update` / `docs_update`.
2249#[must_use]
2250pub fn render_opset_plan(opset: &Opset) -> String {
2251    let mut lines = Vec::new();
2252    for p in &opset.ops {
2253        let subject = p.blocks.first().map_or("(new)", String::as_str);
2254        let conf = p.confidence.map_or(String::new(), |c| format!(" ~{c:.2}"));
2255        let why = p
2256            .reason
2257            .as_ref()
2258            .map_or(String::new(), |r| format!(" [{r}]"));
2259        lines.push(format!(
2260            "{} {:<9} {}{conf}{why}",
2261            verb(p.disposition),
2262            subject,
2263            p.disposition.as_str()
2264        ));
2265    }
2266    let s = &opset.summary;
2267    lines.push(String::new());
2268    lines.push(format!(
2269        "preserved: {}  updated: {}  moved: {}  created: {}  removed: {}  split: {}  merged: {}  ambiguous: {}",
2270        s.preserved, s.updated, s.moved, s.created, s.removed, s.split, s.merged, s.ambiguous
2271    ));
2272    if !opset.converges {
2273        lines.push(
2274            "WARNING: plan does not reproduce the proposed content exactly — will not apply."
2275                .to_owned(),
2276        );
2277    }
2278    lines.join("\n")
2279}
2280
2281#[cfg(test)]
2282mod tests {
2283    use super::*;
2284    use omgbase_store::{MemDocStore, SequentialMinter};
2285
2286    fn surface() -> Surface {
2287        let mut store =
2288            Store::open_in_memory_with_minter(Box::new(SequentialMinter::new())).unwrap();
2289        let repo = store.create_repo("fixture").unwrap();
2290        let mut n = 0;
2291        Surface::new(store, &repo, None)
2292            .with_doc_store(Box::new(MemDocStore::new()))
2293            .with_clock(move || {
2294                n += 1;
2295                format!("2026-09-26T10:{n:02}:00.000Z")
2296            })
2297    }
2298
2299    #[test]
2300    fn catalog_lists_every_tool_of_the_table() {
2301        let names: Vec<&str> = tools().iter().map(|t| t.name).collect();
2302        for want in [
2303            "docs_outline",
2304            "docs_read",
2305            "docs_get_many",
2306            "nodes_get",
2307            "nodes_get_many",
2308            "read_ref",
2309            "docs_tree",
2310            "docs_list",
2311            "query_syntax",
2312            "query",
2313            "graph",
2314            "text_search",
2315            "resolve",
2316            "apply",
2317            "blocks_insert",
2318            "blocks_update",
2319            "blocks_move",
2320            "blocks_remove",
2321            "blocks_split",
2322            "blocks_merge",
2323            "tasks_complete",
2324            "node_set",
2325            "sections_append",
2326            "docs_append",
2327            "links_retarget",
2328            "links_stale",
2329            "links_repair",
2330            "docs_create",
2331            "docs_move",
2332            "docs_delete",
2333            "docs_set_meta",
2334            "docs_plan_update",
2335            "docs_update",
2336            "observe",
2337            "observe_many",
2338            "observe_delete",
2339            "history_node",
2340            "diff",
2341            "diff_unified",
2342            "docs_read_at",
2343            "docs_history",
2344            "changes_since",
2345            "repos_status",
2346            "sync_status",
2347            "repos",
2348            "version",
2349        ] {
2350            assert!(names.contains(&want), "missing {want}");
2351        }
2352        assert_eq!(names.len(), 46);
2353        for t in tools() {
2354            assert_eq!(t.input_schema["type"], "object");
2355        }
2356    }
2357
2358    #[test]
2359    fn observe_read_and_query_round_trip() {
2360        let mut s = surface();
2361        let out = s.call("observe", json!({ "path": "a.md", "content": "---\nlayer: canon\n---\n# Title\n\nHello world.\n\n- [ ] task one\n" }));
2362        assert!(!out.is_error, "{}", out.body);
2363        assert_eq!(out.body["docId"], "d_0");
2364        assert_eq!(out.body["echo"], false);
2365        let read = s.call("docs_read", json!({ "doc": "a.md", "include_ids": true }));
2366        assert!(!read.is_error);
2367        assert_eq!(read.body["path"], "a.md");
2368        assert_eq!(read.body["properties"]["frontmatter"]["layer"], "canon");
2369        assert!(read.body["ids"].as_array().unwrap().len() >= 3);
2370        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" }));
2371        assert!(!q.is_error, "{}", q.body);
2372        assert_eq!(q.body["hits"][0]["$title"], "Title");
2373        assert_eq!(q.body["hits"][0]["layer"], "canon");
2374        assert_eq!(q.body["hits"][0]["t"][0]["value"], "task one");
2375        assert_eq!(q.body["hits"][0]["id"], "d_0");
2376        assert_eq!(q.body["consumer"], "collect");
2377        let c = s.call(
2378            "query",
2379            json!({ "query": "$repo.blocks count { where text(\"hello\") }" }),
2380        );
2381        assert_eq!(c.body["count"], 1);
2382        let bad = s.call(
2383            "query",
2384            json!({ "query": "from docs where path == \"a.md\"" }),
2385        );
2386        assert!(bad.is_error);
2387        assert_eq!(bad.body["error"], "filter_invalid");
2388        assert!(
2389            bad.body["message"]
2390                .as_str()
2391                .unwrap()
2392                .contains("did you mean the intrinsic $path")
2393        );
2394        let sem = s.call(
2395            "query",
2396            json!({ "query": "from docs where semantic(\"x\") > 0.5" }),
2397        );
2398        assert_eq!(sem.body["error"], "semantic_unavailable");
2399        let outline = s.call("docs_outline", json!({ "path": "a.md" }));
2400        let text = outline.body["text"].as_str().unwrap();
2401        assert!(text.starts_with("b_0 h1   Title  §"), "{text}");
2402        assert!(text.contains("☐ task one"));
2403        let missing = s.call("docs_read", json!({ "doc": "nope.md" }));
2404        assert_eq!(missing.body["error"], "doc_missing");
2405        let repos = s.call("repos", json!({}));
2406        assert_eq!(repos.body["repos"][0]["slug"], "fixture");
2407        let unknown = s.call("docs_list", json!({ "repo": "zzz" }));
2408        assert_eq!(unknown.body["error"], "repo_not_found");
2409    }
2410
2411    #[test]
2412    fn insert_and_move_carry_the_destination_parent_cas_and_many_reads_keep_one_item() {
2413        let mut s = surface();
2414        s.call(
2415            "observe",
2416            json!({ "path": "a.md", "content": "# T\n\nOne.\n\nTwo.\n" }),
2417        );
2418        s.call("observe", json!({ "path": "b.md", "content": "# U\n" }));
2419        // §4 (1.2): a stale `expect.parent_children_hash` on blocks_insert is
2420        // the kernel's destination-parent CAS: `stale_expectation` carrying the
2421        // current hash, from which the caller retries without a read.
2422        let stale = s.call(
2423            "blocks_insert",
2424            json!({ "to": "a.md", "markdown": "Three.", "expect": { "parent_children_hash": "00" } }),
2425        );
2426        assert!(stale.is_error, "{}", stale.body);
2427        assert_eq!(stale.body["error"], "stale_expectation");
2428        assert_eq!(stale.body["retriable"], true);
2429        let current = stale.body["data"]["current"]["parent_children_hash"]
2430            .as_str()
2431            .expect("current hash")
2432            .to_owned();
2433        assert_eq!(current.len(), 64, "{current}");
2434        let ok = s.call(
2435            "blocks_insert",
2436            json!({ "to": "a.md", "markdown": "Three.", "expect": { "parent_children_hash": current, "content_hash": "dropped, not checked" } }),
2437        );
2438        assert!(!ok.is_error, "{}", ok.body);
2439        assert_eq!(ok.body["committed"], true);
2440        let read = s.call("docs_read", json!({ "doc": "a.md" }));
2441        assert_eq!(read.body["content"], "# T\n\nOne.\n\nTwo.\n\nThree.\n");
2442        // blocks_move: the destination is checked once for the whole run.
2443        let stale = s.call(
2444            "blocks_move",
2445            json!({ "blocks": ["b_1", "b_2"], "to": "a.md", "at": "end", "expect": { "parent_children_hash": "00" } }),
2446        );
2447        assert_eq!(stale.body["error"], "stale_expectation", "{}", stale.body);
2448        let current = stale.body["data"]["current"]["parent_children_hash"]
2449            .as_str()
2450            .expect("current hash")
2451            .to_owned();
2452        let ok = s.call(
2453            "blocks_move",
2454            json!({ "blocks": ["b_1", "b_2"], "to": "a.md", "at": "end", "expect": { "parent_children_hash": current } }),
2455        );
2456        assert!(!ok.is_error, "{}", ok.body);
2457        let read = s.call("docs_read", json!({ "doc": "a.md" }));
2458        assert_eq!(read.body["content"], "# T\n\nThree.\n\nOne.\n\nTwo.\n\n");
2459        // A `dry_run` still runs the CAS.
2460        let dry = s.call(
2461            "blocks_move",
2462            json!({ "blocks": ["b_1"], "to": "a.md", "at": "start", "dry_run": true, "expect": { "parent_children_hash": "00" } }),
2463        );
2464        assert_eq!(dry.body["error"], "stale_expectation", "{}", dry.body);
2465        // §2 (1.2): the budget is applied from the second item on — one item
2466        // always comes back, `truncated` says more remained.
2467        let many = s.call(
2468            "docs_get_many",
2469            json!({ "docs": ["a.md", "b.md"], "budget_tokens": 1 }),
2470        );
2471        assert_eq!(
2472            many.body["items"].as_array().unwrap().len(),
2473            1,
2474            "{}",
2475            many.body
2476        );
2477        assert_eq!(many.body["items"][0]["path"], "a.md");
2478        assert_eq!(many.body["truncated"], true);
2479        let one = s.call(
2480            "docs_get_many",
2481            json!({ "docs": ["b.md"], "budget_tokens": 1 }),
2482        );
2483        assert_eq!(one.body["items"].as_array().unwrap().len(), 1);
2484        assert_eq!(one.body["truncated"], false);
2485        let nodes = s.call(
2486            "nodes_get_many",
2487            json!({ "ids": ["b_zzz", "b_1", "b_2"], "resolution": "full", "budget_tokens": 1 }),
2488        );
2489        assert_eq!(
2490            nodes.body["nodes"].as_array().unwrap().len(),
2491            1,
2492            "{}",
2493            nodes.body
2494        );
2495        assert_eq!(nodes.body["nodes"][0]["id"], "b_1");
2496        assert_eq!(nodes.body["truncated"], true);
2497        assert_eq!(nodes.body["unresolved"], json!(["b_zzz"]));
2498    }
2499
2500    #[test]
2501    fn writes_go_through_the_fixed_doc_store_and_fire_the_hook() {
2502        use std::cell::Cell;
2503        use std::rc::Rc;
2504        let fired = Rc::new(Cell::new(0));
2505        let f2 = Rc::clone(&fired);
2506        let mut s = surface().with_mutation_hook(move || f2.set(f2.get() + 1));
2507        s.call(
2508            "observe",
2509            json!({ "path": "a.md", "content": "# T\n\nOne.\n" }),
2510        );
2511        assert_eq!(fired.get(), 1);
2512        let dry = s.call(
2513            "blocks_insert",
2514            json!({ "to": "a.md", "markdown": "Two.", "dry_run": true }),
2515        );
2516        assert!(!dry.is_error, "{}", dry.body);
2517        assert_eq!(dry.body["committed"], false);
2518        assert_eq!(fired.get(), 1, "a dry run never fires the hook");
2519        let wet = s.call("blocks_insert", json!({ "to": "a.md", "markdown": "Two." }));
2520        assert!(!wet.is_error, "{}", wet.body);
2521        assert_eq!(fired.get(), 2);
2522        let read = s.call("docs_read", json!({ "doc": "d_0" }));
2523        assert_eq!(read.body["content"], "# T\n\nOne.\n\nTwo.\n");
2524        let upd = s.call(
2525            "blocks_update",
2526            json!({ "block": "b_1", "markdown": "One, edited." }),
2527        );
2528        assert!(!upd.is_error, "{}", upd.body);
2529        assert_eq!(upd.body["id"], "b_1");
2530        let app = s.call(
2531            "sections_append",
2532            json!({ "heading": "T", "markdown": "Three." }),
2533        );
2534        assert!(!app.is_error, "{}", app.body);
2535        let amb = s.call(
2536            "sections_append",
2537            json!({ "heading": "Nope", "markdown": "x" }),
2538        );
2539        assert_eq!(amb.body["error"], "parent_missing");
2540        let hist = s.call("history_node", json!({ "id": "b_1" }));
2541        let entries = hist.body.as_array().unwrap();
2542        assert!(entries.len() >= 2, "{}", hist.body);
2543        assert_eq!(entries[0]["origin"], "api", "newest first");
2544        let du = s.call("diff_unified", json!({ "doc": "a.md" }));
2545        assert!(!du.is_error, "{}", du.body);
2546        assert!(du.body["diff"].as_str().unwrap().contains("+Three."));
2547    }
2548
2549    #[test]
2550    fn doc_tools_dry_run_preview_diffs_and_commit_nothing() {
2551        use std::cell::Cell;
2552        use std::rc::Rc;
2553        let fired = Rc::new(Cell::new(0));
2554        let f2 = Rc::clone(&fired);
2555        let mut s = surface().with_mutation_hook(move || f2.set(f2.get() + 1));
2556        let b_src = "# B\n\nTarget.\n";
2557        let a_src = "# A\n\nSee [b](b.md).\n";
2558        let b = s.call("docs_create", json!({ "path": "b.md", "markdown": b_src }));
2559        assert_eq!(b.body["docId"], "d_0", "{}", b.body);
2560        let a = s.call("docs_create", json!({ "path": "a.md", "markdown": a_src }));
2561        assert_eq!(a.body["docId"], "d_1", "{}", a.body);
2562        assert_eq!(fired.get(), 2);
2563        let count = |s: &Surface, table: &str| -> i64 {
2564            s.store
2565                .conn()
2566                .query_row(&format!("SELECT count(*) FROM {table}"), [], |r| r.get(0))
2567                .unwrap()
2568        };
2569        let snapshot = |s: &Surface| {
2570            (
2571                count(s, "docs"),
2572                count(s, "commits"),
2573                count(s, "revisions"),
2574                count(s, "blocks"),
2575            )
2576        };
2577        let before = snapshot(&s);
2578        let keys = |v: &Json| -> Vec<String> { v.as_object().unwrap().keys().cloned().collect() };
2579
2580        // docs_create: `""` → the composed file; the `d_` id minted as the real run would.
2581        let c = s.call(
2582            "docs_create",
2583            json!({ "path": "c.md", "markdown": "# C", "frontmatter": { "title": "C" }, "dry_run": true }),
2584        );
2585        assert!(!c.is_error, "{}", c.body);
2586        assert_eq!(
2587            serde_json::to_string(&c.body).unwrap(),
2588            r#"{"docId":"d_2","path":"c.md","committed":false,"diffs":{"c.md":{"before":"","after":"---\ntitle: C\n---\n\n# C\n"}}}"#,
2589            "the reference's key order, byte for byte"
2590        );
2591
2592        // docs_move with retarget_inbound: old path emptied, new path filled,
2593        // the rewritten source appended; `dangling`/`retargeted` previewed.
2594        let m = s.call(
2595            "docs_move",
2596            json!({ "doc": "b.md", "to_path": "notes/b.md", "retarget_inbound": true, "dry_run": true }),
2597        );
2598        assert!(!m.is_error, "{}", m.body);
2599        // Byte for byte what the reference prints for
2600        // `docsMove(store, { ...ctx, dryRun: true }, "b.md", "notes/b.md", { retargetInbound: true })`
2601        // over the same two documents under the sequential minter.
2602        assert_eq!(
2603            serde_json::to_string(&m.body).unwrap(),
2604            r##"{"docId":"d_0","path":"notes/b.md","committed":false,"diffs":{"b.md":{"before":"# B\n\nTarget.\n","after":""},"notes/b.md":{"before":"","after":"# B\n\nTarget.\n"},"a.md":{"before":"# A\n\nSee [b](b.md).\n","after":"# A\n\nSee [b](notes/b.md).\n"}},"dangling":[],"retargeted":{"blocks":["b_3"],"docs":["d_1"]}}"##
2605        );
2606        assert_eq!(
2607            keys(&m.body),
2608            [
2609                "docId",
2610                "path",
2611                "committed",
2612                "diffs",
2613                "dangling",
2614                "retargeted"
2615            ]
2616        );
2617        assert_eq!(keys(&m.body["diffs"]), ["b.md", "notes/b.md", "a.md"]);
2618        assert_eq!(
2619            m.body["diffs"]["a.md"],
2620            json!({ "before": a_src, "after": "# A\n\nSee [b](notes/b.md).\n" })
2621        );
2622        // … and without `retarget_inbound`: two entries, the inbound link dangles.
2623        let plain = s.call(
2624            "docs_move",
2625            json!({ "doc": "b.md", "to_path": "notes/b.md", "dry_run": true }),
2626        );
2627        assert_eq!(
2628            serde_json::to_string(&plain.body).unwrap(),
2629            r##"{"docId":"d_0","path":"notes/b.md","committed":false,"diffs":{"b.md":{"before":"# B\n\nTarget.\n","after":""},"notes/b.md":{"before":"","after":"# B\n\nTarget.\n"}},"dangling":[{"doc":"d_1","path":"a.md","block":"b_3","target":"b.md","anchor":null}],"retargeted":null}"##
2630        );
2631
2632        // docs_delete: the file → `""`.
2633        let d = s.call("docs_delete", json!({ "doc": "a.md", "dry_run": true }));
2634        assert!(!d.is_error, "{}", d.body);
2635        assert_eq!(
2636            d.body,
2637            json!({ "docId": "d_1", "path": "a.md", "committed": false, "diffs": { "a.md": { "before": a_src, "after": "" } } })
2638        );
2639        assert_eq!(keys(&d.body), ["docId", "path", "committed", "diffs"]);
2640
2641        // docs_set_meta: before → after.
2642        let sm = s.call(
2643            "docs_set_meta",
2644            json!({ "doc": "a.md", "set": { "status": "open" }, "dry_run": true }),
2645        );
2646        assert!(!sm.is_error, "{}", sm.body);
2647        assert_eq!(
2648            sm.body,
2649            json!({ "docId": "d_1", "path": "a.md", "committed": false, "diffs": { "a.md": { "before": a_src, "after": "---\nstatus: open\n---\n\n# A\n\nSee [b](b.md).\n" } } })
2650        );
2651
2652        // Nothing committed, no hook, no row changed; the checks still run.
2653        assert_eq!(fired.get(), 2, "dry runs never fire the mutation hook");
2654        assert_eq!(snapshot(&s), before);
2655        assert_eq!(
2656            s.call("docs_read", json!({ "doc": "d_0" })).body["path"],
2657            "b.md"
2658        );
2659        assert_eq!(
2660            s.call("docs_read", json!({ "doc": "d_1" })).body["content"],
2661            a_src
2662        );
2663        let taken = s.call(
2664            "docs_create",
2665            json!({ "path": "a.md", "markdown": "x", "dry_run": true }),
2666        );
2667        assert_eq!(taken.body["error"], "path_taken");
2668        let missing = s.call("docs_delete", json!({ "doc": "nope.md", "dry_run": true }));
2669        assert_eq!(missing.body["error"], "doc_missing");
2670        // The dry-run create spent `d_2`: the next real create is `d_3` and carries no `diffs`.
2671        let real = s.call(
2672            "docs_create",
2673            json!({ "path": "c.md", "markdown": "# C\n" }),
2674        );
2675        assert_eq!(
2676            real.body,
2677            json!({ "docId": "d_3", "path": "c.md", "committed": true })
2678        );
2679        assert_eq!(fired.get(), 3);
2680    }
2681}