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