Skip to main content

omgbase_surface/
catalog.rs

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