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