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