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");
1156    // at least one is required, and when both are given they must name the
1157    // same document. Every `docs_*` tool — `docs_diff` and `docs_diff_unified`
1158    // included — locates under the same rules (5.0: the arguments are the
1159    // row's engine attributes, spelled exactly; a bare `id`/`path` never
1160    // reaches here — `validate_arguments` refuses it with the `_` spelling and
1161    // a `fix`). A miss is `doc_missing` "no document with _id or _path
1162    // "<value>"" with the arguments as given in `data`; neither given is
1163    // `filter_invalid` "pass _id or _path"; a disagreement is `filter_invalid`
1164    // "_id "…" and _path "…" name different documents". The reference's
1165    // `locateDoc`, byte for byte.
1166    /// A document locator (`_id` or `_path`, either form) resolved to the live
1167    /// document's id under the `docs_update` tool's rules — the error a
1168    /// `docs_update` with that locator would raise (`doc_missing` with the
1169    /// given keys as `data`). The CLI's local `update <doc>` resolves its
1170    /// target here before planning, as the reference's `requireDoc` does.
1171    pub fn locate_document(&self, repo_id: &str, args: &Json) -> Result<String> {
1172        self.locate_doc(repo_id, "docs_update", args)
1173    }
1174
1175    /// `docs_read { at }` and its sugar `docs_read_at` (`spec/surface` §2,
1176    /// 4.7): the document AS OF a revision, `spec/store` §6.2 reconstruct; a
1177    /// revision that is not the document's — unknown, or another document's —
1178    /// is `target_missing { doc, rev }`. The reference's `docsReadAt`.
1179    fn docs_read_at(&self, doc_id: &str, rev: &str) -> Result<Json> {
1180        read::docs_read_at(&self.store, doc_id, rev)?.ok_or_else(|| {
1181            SurfaceError::with_data(
1182                "target_missing",
1183                format!(
1184                    "no revision {} for document {doc_id}",
1185                    Json::String(rev.to_owned())
1186                ),
1187                json!({ "doc": doc_id, "rev": rev }),
1188            )
1189        })
1190    }
1191
1192    fn locate_doc(&self, repo_id: &str, tool: &str, args: &Json) -> Result<String> {
1193        let (id_key, path_key) = ("_id", "_path");
1194        let schema = &tool_spec(tool).expect("a catalog tool").input_schema;
1195        let id = arg_str(args, id_key);
1196        let path = arg_str(args, path_key);
1197        if id.is_none() && path.is_none() {
1198            return Err(argument_error(
1199                tool,
1200                &format!("pass {id_key} or {path_key}"),
1201                schema,
1202            ));
1203        }
1204        let mut given = Map::new();
1205        if let Some(v) = id {
1206            given.insert(id_key.to_owned(), json!(v));
1207        }
1208        if let Some(v) = path {
1209            given.insert(path_key.to_owned(), json!(v));
1210        }
1211        let missing = |value: &str| {
1212            SurfaceError::with_data(
1213                "doc_missing",
1214                format!(
1215                    "no document with {id_key} or {path_key} {}",
1216                    Json::String(value.to_owned())
1217                ),
1218                Json::Object(given.clone()),
1219            )
1220        };
1221        let conn = self.store.conn();
1222        let by_id = match id {
1223            Some(v) => {
1224                let info = read::find_doc_by_id(conn, v)?.filter(|i| i.repo_id == repo_id);
1225                Some(info.ok_or_else(|| missing(v))?.doc_id)
1226            }
1227            None => None,
1228        };
1229        let by_path = match path {
1230            Some(v) => Some(
1231                read::find_doc_by_path(conn, repo_id, v)?
1232                    .ok_or_else(|| missing(v))?
1233                    .doc_id,
1234            ),
1235            None => None,
1236        };
1237        if let (Some(a), Some(b)) = (&by_id, &by_path)
1238            && a != b
1239        {
1240            return Err(argument_error(
1241                tool,
1242                &format!(
1243                    "{id_key} {} and {path_key} {} name different documents",
1244                    Json::String(id.unwrap_or_default().to_owned()),
1245                    Json::String(path.unwrap_or_default().to_owned())
1246                ),
1247                schema,
1248            ));
1249        }
1250        Ok(by_id.or(by_path).expect("one locator given"))
1251    }
1252
1253    /// `semantic()` is loud when it cannot rank (§4, 3.0): a provider that
1254    /// answers but whose model has embedded NOTHING in this repository would
1255    /// score every row null and hand back path-ordered rows with no score, so
1256    /// a query naming a phrase (`semantic`) fails `provider_unavailable` up
1257    /// front, naming the provider and model and what to run. No provider at
1258    /// all is the arm's own `provider_unavailable` (checked before this).
1259    fn require_rankable(&self, repo_id: &str, semantic: bool) -> Result<()> {
1260        let Some(provider) = self.provider.as_deref().filter(|_| semantic) else {
1261            return Ok(());
1262        };
1263        let model = provider.model();
1264        let any: Option<i64> = self
1265            .store
1266            .conn()
1267            .query_row(
1268                "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)",
1269                params![repo_id, model],
1270                |r| r.get(0),
1271            )
1272            .optional()?;
1273        if any.is_some() {
1274            return Ok(());
1275        }
1276        let who = match &self.provider_name {
1277            Some(name) => format!("provider {} (model {model})", Json::String(name.clone())),
1278            None => format!("model {model}"),
1279        };
1280        Err(SurfaceError::with_data(
1281            "provider_unavailable",
1282            format!(
1283                "semantic(...) cannot rank: {who} is configured but nothing in this repository is embedded under it — run omg embed drain"
1284            ),
1285            json!({ "provider": self.provider_name, "model": model }),
1286        ))
1287    }
1288
1289    /// The owning document of a live block (`blocks_read`).
1290    fn doc_of_block(&self, block_id: &str) -> Result<String> {
1291        self.doc_id_of_block(block_id)?
1292            .ok_or_else(|| block_missing(block_id))
1293    }
1294
1295    /// `docs_append`'s `section`: a heading's TEXT, matched against the
1296    /// normalized heading text of the named document's live headings (an
1297    /// ATX-looking value, "## Title", loses its hashes). Zero →
1298    /// `parent_missing` naming the heading; several → `ambiguous_heading` with
1299    /// the candidates `[{ _id, _doc_id }]` (pass the heading's id to
1300    /// `blocks_append` instead). Never an id lookup — the id form is
1301    /// `blocks_append`'s (§9).
1302    ///
1303    /// Each miss carries a `fix` (§9, 4.4): several matches → `blocks_append`
1304    /// with the first candidate's id; none → the one heading whose text
1305    /// matches up to ASCII case, as `blocks_append`, else the same
1306    /// `docs_append` without `section` (the document's end). `call` is the
1307    /// tool's own locator and text.
1308    fn resolve_section_heading(
1309        &self,
1310        doc_id: &str,
1311        doc_path: &str,
1312        section: &str,
1313        call: (Option<&str>, Option<&str>, &str),
1314    ) -> Result<String> {
1315        let conn = self.store.conn();
1316        let needle = if section.trim_start_matches([' ', '\t']).starts_with('#') {
1317            normalize_visible_text(section, BlockKind::Heading, 0)
1318        } else {
1319            normalize_text(section)
1320        };
1321        let mut stmt = conn.prepare(
1322            "SELECT block_id, doc_id, text FROM blocks WHERE doc_id = ?1 AND type = 'heading' AND deleted_commit IS NULL",
1323        )?;
1324        let rows: Vec<(String, String, String)> = stmt
1325            .query_map(params![doc_id], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?
1326            .collect::<std::result::Result<_, _>>()?;
1327        let matches: Vec<&(String, String, String)> = rows
1328            .iter()
1329            .filter(|(_, _, text)| normalize_text(text) == needle)
1330            .collect();
1331        let shown = reference_path(doc_path);
1332        let (id, path, text) = call;
1333        match matches.len() {
1334            0 => {
1335                let near: Vec<&(String, String, String)> = rows
1336                    .iter()
1337                    .filter(|(_, _, t)| normalize_text(t).eq_ignore_ascii_case(&needle))
1338                    .collect();
1339                let fix = match near.as_slice() {
1340                    [one] => fixes::append_into_heading(&one.0, text),
1341                    _ => fixes::append_without_section(id, path, text),
1342                };
1343                Err(SurfaceError::with_data(
1344                    "parent_missing",
1345                    format!("no heading {} in {shown}", Json::String(section.to_owned())),
1346                    json!({ "section": section, "_path": shown, "fix": fix }),
1347                ))
1348            }
1349            1 => Ok(matches[0].0.clone()),
1350            n => Err(SurfaceError::with_data(
1351                "ambiguous_heading",
1352                format!(
1353                    "heading {} matches {n} headings in {shown}; pass the heading's id to blocks_append",
1354                    Json::String(section.to_owned())
1355                ),
1356                json!({
1357                    "section": section,
1358                    "_path": shown,
1359                    "candidates": matches.iter().map(|(b, d, _)| json!({ "_id": b, "_doc_id": d })).collect::<Vec<_>>(),
1360                    "fix": fixes::append_into_heading(&matches[0].0, text),
1361                }),
1362            )),
1363        }
1364    }
1365
1366    /// `nodes_block_read`: the `(doc_id, block_id)` of the live block a node
1367    /// was projected from — `block_missing` for an unknown node ("no node with
1368    /// id …") or a node with no live block ("node … has no block").
1369    fn block_of_node(&self, node_id: &str) -> Result<(String, String)> {
1370        let node: Option<(String, Option<String>)> = self
1371            .store
1372            .conn()
1373            .query_row(
1374                "SELECT doc_id, block_id FROM nodes WHERE node_id = ?1",
1375                params![node_id],
1376                |r| Ok((r.get(0)?, r.get(1)?)),
1377            )
1378            .optional()?;
1379        let Some((doc_id, block_id)) = node else {
1380            return Err(SurfaceError::with_data(
1381                "block_missing",
1382                format!("no node with id {}", Json::String(node_id.to_owned())),
1383                json!({ "_id": node_id }),
1384            ));
1385        };
1386        let live = match &block_id {
1387            Some(b) => self.doc_id_of_block(b)?.is_some(),
1388            None => false,
1389        };
1390        match block_id {
1391            Some(b) if live => Ok((doc_id, b)),
1392            _ => Err(node_has_no_block(node_id, &doc_id)),
1393        }
1394    }
1395
1396    /// A ref → a live block id (`block_missing` otherwise).
1397    fn resolve_block_ref(&self, repo_id: &str, r: &str) -> Result<String> {
1398        match read::resolve_ref(self.store.conn(), repo_id, r)? {
1399            Some(ResolvedRef::Block { block_id, .. }) => Ok(block_id),
1400            _ => Err(block_missing(r)),
1401        }
1402    }
1403
1404    /// A parent ref: a block, or a document ref for its top level.
1405    fn resolve_parent_ref(&self, repo_id: &str, r: &str) -> Result<(Parent, String)> {
1406        match read::resolve_ref(self.store.conn(), repo_id, r)? {
1407            Some(ResolvedRef::Block { doc_id, block_id }) => Ok((Parent::Block(block_id), doc_id)),
1408            Some(ResolvedRef::Document { doc_id }) => Ok((Parent::Doc, doc_id)),
1409            None => Err(SurfaceError::with_data(
1410                "block_missing",
1411                format!(
1412                    "no block or document with id or path {}",
1413                    Json::String(r.to_owned())
1414                ),
1415                json!({ "ref": r }),
1416            )),
1417        }
1418    }
1419
1420    fn doc_id_of_block(&self, block_id: &str) -> Result<Option<String>> {
1421        Ok(self
1422            .store
1423            .conn()
1424            .query_row(
1425                "SELECT doc_id FROM blocks WHERE block_id = ?1 AND deleted_commit IS NULL",
1426                params![block_id],
1427                |r| r.get(0),
1428            )
1429            .optional()?)
1430    }
1431
1432    /// The `at` spec with its anchor ref resolved; absent → end.
1433    fn resolve_at(&self, repo_id: &str, at: Option<&Json>) -> Result<At> {
1434        match at {
1435            None | Some(Json::Null) => Ok(At::End),
1436            Some(Json::String(s)) if s == "start" => Ok(At::Start),
1437            Some(Json::String(s)) if s == "end" => Ok(At::End),
1438            Some(Json::Object(o)) => {
1439                if let Some(b) = o.get("before").and_then(Json::as_str) {
1440                    return Ok(At::Before(self.resolve_block_ref(repo_id, b)?));
1441                }
1442                if let Some(a) = o.get("after").and_then(Json::as_str) {
1443                    return Ok(At::After(self.resolve_block_ref(repo_id, a)?));
1444                }
1445                Err(bad_args(
1446                    "`at` must be \"start\", \"end\", {before} or {after}",
1447                ))
1448            }
1449            Some(_) => Err(bad_args(
1450                "`at` must be \"start\", \"end\", {before} or {after}",
1451            )),
1452        }
1453    }
1454
1455    // ---- the apply tail -----------------------------------------------------------------
1456
1457    /// A sugar tool's effects through the planner (ADR-021): `plan` +
1458    /// `execute` in one call under one write scope and one transaction,
1459    /// `reason` the tool's name — the Receipt (§4, 4.0); previewing
1460    /// ([`Surface::preview_call`]) the Plan alone, nothing committed. The
1461    /// mutation hook fires on a commit.
1462    fn apply_sugar(
1463        &mut self,
1464        repo_id: &str,
1465        root: Option<&str>,
1466        effects: Vec<PublicEffect>,
1467        reason: &str,
1468    ) -> Result<Json> {
1469        let out = self.apply_sugar_with(repo_id, root, reason, |_, _| Ok(effects))?;
1470        Ok(sugar_json(&out))
1471    }
1472
1473    /// [`Surface::apply_sugar`]'s seam for the tools whose result carries
1474    /// more than the Receipt (`docs_move`, `links_*`): `build` runs under the
1475    /// write scope with the store and the doc store — the tool's own
1476    /// pre-checks, then its effects — and the plan and the receipt (`None`
1477    /// when previewing) come back for the tool to shape. The plan is kept for
1478    /// [`Surface::call_result_with_plan`].
1479    fn apply_sugar_with(
1480        &mut self,
1481        repo_id: &str,
1482        root: Option<&str>,
1483        reason: &str,
1484        build: impl FnOnce(&mut Store, &mut dyn DocStore) -> Result<Vec<PublicEffect>>,
1485    ) -> Result<SugarOutcome> {
1486        let origin = ApplyOrigin::new(&self.actor, Some(reason));
1487        let ctx = self.plan_ctx(repo_id);
1488        let preview = self.preview;
1489        let out = self.with_writes(root, |store, ds| {
1490            let effects = build(store, ds)?;
1491            if preview {
1492                let plan = store
1493                    .plan_effects(&ctx, &effects, ds)
1494                    .map_err(strip_effect_index)?;
1495                return Ok((plan, None));
1496            }
1497            let (plan, receipt) = store
1498                .apply_effects_detailed(&ctx, &effects, &origin, ds)
1499                .map_err(strip_effect_index)?;
1500            Ok((plan, Some(receipt)))
1501        })?;
1502        self.last_plan = Some(out.0.clone());
1503        if out.1.is_some() {
1504            self.notify();
1505        }
1506        Ok(out)
1507    }
1508
1509    /// A located document's row (`locate_doc` has already resolved it, so a
1510    /// miss here is a race) as `{ doc_id, path, current_rev }`.
1511    fn doc_info(&self, repo_id: &str, doc_ref: &str) -> Result<omgbase_store::DocInfo> {
1512        self.store
1513            .find_doc_by_ref(repo_id, doc_ref)?
1514            .ok_or_else(|| {
1515                SurfaceError::from(MutationError::new(
1516                    ErrorCode::DocMissing,
1517                    format!("no document {doc_ref}"),
1518                ))
1519            })
1520    }
1521
1522    /// Run a write tool and hand back its result **with the Plan it
1523    /// committed** (`spec/cli` §4, the Receipt confirmation): a sugar tool's
1524    /// or `apply`'s plan, whose `preview.results[].ids` realized through the
1525    /// receipt's `new_ids` are the ids the CLI prints; `None` for a tool that
1526    /// planned nothing (a read, `execute`, an error). Not a tool: the CLI's
1527    /// local seam, as the reference's library-bound verbs hold the plan from
1528    /// `applyEffectsDetailed`.
1529    pub fn call_result_with_plan(
1530        &mut self,
1531        name: &str,
1532        args: &Json,
1533    ) -> Result<(Json, Option<Json>)> {
1534        self.last_plan = None;
1535        let result = self.call_result(name, args);
1536        let plan = self.last_plan.take().map(|p| plan_json(&p));
1537        Ok((result?, plan))
1538    }
1539
1540    /// The Plan a sugar tool would commit (`spec/cli` §3.6: every dry run is
1541    /// a `plan`) — the tool's own ref resolution and pre-checks run, its
1542    /// effects are planned under the symbolic minter, nothing is written and
1543    /// no id is minted; the mutation hook stays quiet. `apply` previews its
1544    /// effects the same way. Not a tool: the catalog has no `dry_run` (4.0),
1545    /// a client previews with `plan { effects }`.
1546    pub fn preview_call(&mut self, name: &str, args: &Json) -> Result<Json> {
1547        self.preview = true;
1548        let out = self.call_result(name, args);
1549        self.preview = false;
1550        out
1551    }
1552
1553    /// The `apply` tool's effects: `(plan, receipt)` on the wire — plan +
1554    /// execute in one write scope and one transaction; previewing, the plan
1555    /// and nothing else. With a `select` (§11.8, 4.1) the effects are the
1556    /// templates, the selector is run and expanded under the same write
1557    /// scope, and `expect.max` is required: a one-shot write must bound what
1558    /// it touches (a `plan` first needs no bound).
1559    fn apply_effects_detailed(
1560        &mut self,
1561        repo: &str,
1562        root: Option<&str>,
1563        args: &Json,
1564    ) -> Result<(Json, Option<Json>)> {
1565        let reason = arg_str(args, "reason").map(str::to_owned);
1566        let origin = ApplyOrigin::new(&self.actor, reason.as_deref());
1567        let ctx = self.plan_ctx(repo);
1568        let preview = self.preview;
1569        let (plan, receipt) = match SelectRequest::from_args(args)? {
1570            Some(req) => {
1571                let semantic = self.selector_semantic(repo, req.select_str())?;
1572                self.with_writes(root, |store, ds| {
1573                    if preview {
1574                        return Ok((
1575                            select::plan_selected(store, ds, &ctx, &req, semantic)?,
1576                            None,
1577                        ));
1578                    }
1579                    // One-shot `apply` with a selector must bound it (§11.8):
1580                    // the caller never saw the hits. The fix (3.3) is the same
1581                    // call bounded at the selection's actual size — the
1582                    // selector compiled (reads only) to count it; a selection
1583                    // the compile itself refuses reports its own error and fix.
1584                    if req.has_selector() && req.expect.is_none() {
1585                        let x = select::expand(store, &ctx, ds, &req, semantic)?;
1586                        let cardinality = x.selection.map_or(0, |s| s.cardinality);
1587                        return Err(select::unbounded_apply(&req, cardinality));
1588                    }
1589                    let (plan, receipt) = store.apply_planned(&ctx, &origin, ds, |store, ds| {
1590                        select::plan_selected(store, ds, &ctx, &req, semantic)
1591                    })?;
1592                    Ok((plan, Some(receipt)))
1593                })?
1594            }
1595            None => {
1596                self.with_writes(root, |store, ds| {
1597                    // The shape check with the §8 fixes (3.4): the anchor fix
1598                    // reads the block's live row, so it runs over the store.
1599                    let effects = select::validate_plain(store, &args["effects"])?;
1600                    if preview {
1601                        return Ok((store.plan_effects(&ctx, &effects, ds)?, None));
1602                    }
1603                    let (plan, receipt) =
1604                        store.apply_effects_detailed(&ctx, &effects, &origin, ds)?;
1605                    Ok((plan, Some(receipt)))
1606                })?
1607            }
1608        };
1609        self.last_plan = Some(plan.clone());
1610        if receipt.is_some() {
1611            self.notify();
1612        }
1613        Ok((plan_json(&plan), receipt.as_ref().map(receipt_json)))
1614    }
1615
1616    /// A selector's `semantic(...)` phrases embedded ahead of the write scope
1617    /// (the provider is the surface's, the expansion runs with the store
1618    /// borrowed): no provider is `provider_unavailable`, an unrankable repo
1619    /// is refused as the `query` tool refuses it.
1620    fn selector_semantic(
1621        &self,
1622        repo_id: &str,
1623        select: &str,
1624    ) -> Result<HashMap<String, crate::context::SemanticVec>> {
1625        let phrases = crate::query::collect_semantic_phrases(select);
1626        if self.provider.is_none() && !phrases.is_empty() {
1627            return Err(provider_unavailable());
1628        }
1629        self.require_rankable(repo_id, !phrases.is_empty())?;
1630        embed_phrases(self.provider.as_deref(), phrases)
1631    }
1632
1633    fn plan_ctx(&mut self, repo_id: &str) -> PlanContext {
1634        PlanContext {
1635            repo_id: repo_id.to_owned(),
1636            ts: self.now(),
1637            config: self.config.clone(),
1638        }
1639    }
1640
1641    // ---- dispatch -------------------------------------------------------------------------
1642
1643    /// Run a tool, returning its result or the error — every error of the
1644    /// effect tools carrying its corrected call (`data.fix`, `spec/surface`
1645    /// §4 "Teaching", 4.4; [`crate::fixes`]).
1646    pub fn call_result(&mut self, name: &str, args: &Json) -> Result<Json> {
1647        self.call_result_inner(name, args)
1648            .map_err(|e| fixes::attach(name, args, e, |i| self.allocated_before(args, i)))
1649    }
1650
1651    /// The symbols the first `i` effects of a plain request allocate (a
1652    /// `$new.n` fix renames the symbol to the next one): a symbolic plan over
1653    /// that prefix, nothing written; `None` when it cannot be planned.
1654    fn allocated_before(&mut self, args: &Json, i: usize) -> Option<usize> {
1655        if i == 0 {
1656            return Some(0);
1657        }
1658        let prefix = args.get("effects")?.as_array()?.get(..i)?.to_vec();
1659        let effects = parse_effects(&Json::Array(prefix)).ok()?;
1660        let (repo, root) = self.scope(args).ok()?;
1661        let ctx = self.plan_ctx(&repo);
1662        self.with_writes(root.as_deref(), |store, ds| {
1663            Ok(store.plan_effects(&ctx, &effects, ds)?)
1664        })
1665        .ok()
1666        .map(|p| p.new_ids.len())
1667    }
1668
1669    #[allow(clippy::too_many_lines)]
1670    fn call_result_inner(&mut self, name: &str, args: &Json) -> Result<Json> {
1671        // §4 "Arguments": the shape first, so a wrong name is one
1672        // `filter_invalid` naming the nearest valid one.
1673        let spec =
1674            tool_spec(name).ok_or_else(|| SurfaceError::other(format!("unknown tool {name}")))?;
1675        // (4.5) a bare string in a string-list slot reaches the handler as `[s]`.
1676        let normalized = validate_arguments(name, &spec.input_schema, args)?;
1677        let args = normalized.as_ref().unwrap_or(args);
1678        match name {
1679            "docs_outline" => {
1680                let (repo, _) = self.scope(args)?;
1681                let doc_id = self.locate_doc(&repo, name, args)?;
1682                let skeleton = match arg_str(args, "resolution") {
1683                    None | Some("outline") => false,
1684                    Some("skeleton") => true,
1685                    Some(_) => return Err(bad_args("`resolution` must be skeleton or outline")),
1686                };
1687                read::docs_outline(
1688                    &self.store,
1689                    &doc_id,
1690                    skeleton,
1691                    arg_i64(args, "depth")?,
1692                    arg_usize(args, "budget_tokens")?,
1693                )
1694            }
1695            "docs_read" => {
1696                let (repo, _) = self.scope(args)?;
1697                let doc_id = self.locate_doc(&repo, name, args)?;
1698                if let Some(rev) = arg_str(args, "at") {
1699                    // `at` (4.7): the document AS OF that revision —
1700                    // `docs_read_at`'s code path, so the two return the same
1701                    // bytes. Block ids at a past revision are a later
1702                    // question: `include_ids` is refused with `at`, after the
1703                    // locator (each argument is valid on its own).
1704                    if arg_bool(args, "include_ids")?.unwrap_or(false) {
1705                        return Err(argument_error(
1706                            name,
1707                            "`include_ids` is not available with `at`",
1708                            &tool_spec(name).expect("a catalog tool").input_schema,
1709                        ));
1710                    }
1711                    return self.docs_read_at(&doc_id, rev);
1712                }
1713                read::docs_read(
1714                    &self.store,
1715                    &doc_id,
1716                    arg_bool(args, "include_ids")?.unwrap_or(false),
1717                )?
1718                .ok_or_else(|| {
1719                    SurfaceError::with_data(
1720                        "doc_missing",
1721                        format!(
1722                            "no document with id or path {}",
1723                            Json::String(
1724                                arg_str(args, "_id")
1725                                    .or(arg_str(args, "_path"))
1726                                    .unwrap_or_default()
1727                                    .to_owned()
1728                            )
1729                        ),
1730                        json!({ "_id": doc_id }),
1731                    )
1732                })
1733            }
1734            "docs_read_many" => {
1735                let (repo, _) = self.scope(args)?;
1736                let refs = arg_strings(args, "docs")?;
1737                read::docs_read_many(
1738                    &self.store,
1739                    &repo,
1740                    &refs,
1741                    arg_bool(args, "include_ids")?.unwrap_or(false),
1742                    arg_usize(args, "budget_tokens")?,
1743                )
1744            }
1745            "blocks_read" => {
1746                self.scope(args)?;
1747                let id = arg_string(args, "_id")?;
1748                // `_id` is a block id, unambiguously the `blocks_` kind's (§4
1749                // "Names and arguments"): an `n_` id names a NODE and is
1750                // `block_missing` pointing at `nodes_block_read` (the name pass
1751                // had dereferenced it here, §9).
1752                if id.starts_with("n_") {
1753                    return Err(SurfaceError::with_data(
1754                        "block_missing",
1755                        format!(
1756                            "no block with id {} (an n_ id names a node — nodes_block_read reads its block)",
1757                            Json::String(id.clone())
1758                        ),
1759                        json!({ "_id": id }),
1760                    ));
1761                }
1762                let doc_id = self.doc_of_block(&id)?;
1763                read::blocks_read(
1764                    &self.store,
1765                    &doc_id,
1766                    &id,
1767                    resolution_arg(args, Resolution::Full)?,
1768                )?
1769                .ok_or_else(|| block_missing(&id))
1770            }
1771            "nodes_block_read" => {
1772                self.scope(args)?;
1773                let id = arg_string(args, "_id")?;
1774                // The block a node was projected from: the node must exist and
1775                // name a live block (a block-less node — one derived from the
1776                // frontmatter — has none). Both misses are `block_missing`,
1777                // the message naming the node.
1778                let (doc_id, block_id) = self.block_of_node(&id)?;
1779                read::blocks_read(
1780                    &self.store,
1781                    &doc_id,
1782                    &block_id,
1783                    resolution_arg(args, Resolution::Full)?,
1784                )?
1785                .ok_or_else(|| node_has_no_block(&id, &doc_id))
1786            }
1787            "blocks_read_many" => {
1788                self.scope(args)?;
1789                let ids = arg_strings(args, "_ids")?;
1790                read::blocks_read_many(
1791                    &self.store,
1792                    None,
1793                    &ids,
1794                    resolution_arg(args, Resolution::Text)?,
1795                    arg_usize(args, "budget_tokens")?,
1796                )
1797            }
1798            "docs_tree" => {
1799                let (repo, _) = self.scope(args)?;
1800                read::docs_tree(
1801                    &self.store,
1802                    &repo,
1803                    arg_str(args, "path"),
1804                    arg_i64(args, "depth")?,
1805                    arg_i64(args, "limit")?,
1806                    arg_str(args, "cursor"),
1807                    arg_usize(args, "budget_tokens")?,
1808                )
1809            }
1810            "docs_list" => {
1811                let (repo, _) = self.scope(args)?;
1812                read::docs_list(
1813                    &self.store,
1814                    &repo,
1815                    arg_str(args, "path_glob"),
1816                    arg_i64(args, "limit")?,
1817                    arg_str(args, "cursor"),
1818                    arg_usize(args, "budget_tokens")?,
1819                )
1820            }
1821            "query_syntax" => Ok(json!({ "syntax": QUERY_SYNTAX })),
1822            "query" => {
1823                let (repo, _) = self.scope(args)?;
1824                let source = arg_string(args, "query")?;
1825                // A `semantic(...)` query with no provider is `provider_unavailable`
1826                // here (the runner itself reports `filter_invalid`, §9).
1827                if self.provider.is_none()
1828                    && !crate::query::collect_semantic_phrases(&source).is_empty()
1829                {
1830                    return Err(provider_unavailable());
1831                }
1832                // The named points of `date()` read the clock this surface
1833                // stamps commits with (§1.3, 4.8) — pinned under
1834                // `OMGBASE_SPEC_CLOCK`, the wall clock otherwise.
1835                let now_ms = dates::stamp_ms(&self.now());
1836                let opts = QueryOptions {
1837                    limit: arg_usize(args, "limit")?,
1838                    cursor: arg_str(args, "cursor"),
1839                    provider: self.provider.as_deref(),
1840                    in_memory: false,
1841                    bare: arg_bool(args, "bare")?.unwrap_or(false),
1842                    unknown_names: None,
1843                    vocabulary: Some(&self.vocabulary),
1844                    now_ms: Some(now_ms),
1845                    // (4.11) the call as received, for a parse error's corrected `fix`.
1846                    request: Some(args),
1847                };
1848                self.require_rankable(
1849                    &repo,
1850                    !crate::query::collect_semantic_phrases(&source).is_empty(),
1851                )?;
1852                Ok(query(&self.store, &repo, &source, opts)?.to_json())
1853            }
1854            "graph" => {
1855                let (repo, _) = self.scope(args)?;
1856                let g = GraphArgs {
1857                    roots: arg_strings(args, "roots")?,
1858                    degrees: arg_i64(args, "degrees")?,
1859                    direction: arg_str(args, "direction").map(str::to_owned),
1860                    predicate: arg_str(args, "predicate").map(str::to_owned),
1861                    select: if args.get("select").is_some() {
1862                        arg_strings(args, "select")?
1863                    } else {
1864                        Vec::new()
1865                    },
1866                    max_documents: arg_i64(args, "max_documents")?,
1867                };
1868                if self.provider.is_none()
1869                    && g.select.iter().any(|s| {
1870                        !crate::query::collect_semantic_phrases(&format!("from docs select x: {s}"))
1871                            .is_empty()
1872                    })
1873                {
1874                    return Err(provider_unavailable());
1875                }
1876                self.require_rankable(
1877                    &repo,
1878                    g.select.iter().any(|s| {
1879                        !crate::query::collect_semantic_phrases(&format!("from docs select x: {s}"))
1880                            .is_empty()
1881                    }),
1882                )?;
1883                let now_ms = dates::stamp_ms(&self.now());
1884                graph_neighborhood(
1885                    &self.store,
1886                    &repo,
1887                    &g,
1888                    self.provider.as_deref(),
1889                    Some(now_ms),
1890                )
1891            }
1892            "blocks_search" => {
1893                let (repo, _) = self.scope(args)?;
1894                let q = arg_string(args, "q")?;
1895                let res =
1896                    self.store
1897                        .text_search(&repo, &q, arg_usize(args, "limit")?.unwrap_or(50))?;
1898                Ok(json!({
1899                    "hits": res.hits.iter().map(|h| json!({
1900                        "_id": h.block_id, "_doc_id": h.doc_id, "_path": reference_path(&h.path), "_type": h.block_type, "_text": h.text, "score": h.score,
1901                    })).collect::<Vec<_>>(),
1902                    "truncated": res.truncated,
1903                }))
1904            }
1905            "blocks_resolve" => {
1906                let (repo, _) = self.scope(args)?;
1907                let q = arg_string(args, "query")?;
1908                let vector = match &self.provider {
1909                    Some(p) => Some(QueryVector {
1910                        model: p.model().to_owned(),
1911                        vec: p
1912                            .embed_query(&q)
1913                            .map_err(|e| SurfaceError::new(e.code(), e.to_string()))?,
1914                    }),
1915                    None => None,
1916                };
1917                let hits = self
1918                    .store
1919                    .resolve(&repo, &q, vector, arg_usize(args, "limit")?)?;
1920                // The locator is `<path>#<type>[<ordinal>]`: its path half is rooted
1921                // (one without `#` names no path and is left alone).
1922                let locator = |l: &str| match l.find('#') {
1923                    Some(i) => format!("{}{}", reference_path(&l[..i]), &l[i..]),
1924                    None => l.to_owned(),
1925                };
1926                Ok(Json::Array(hits.iter().map(|h| json!({
1927                    "_id": h.id, "locator": locator(&h.locator), "preview": h.preview, "evidence": evidence_json(&h.evidence),
1928                })).collect()))
1929            }
1930            "apply" => {
1931                let (repo, root) = self.scope(args)?;
1932                self.require_root(root.as_deref())?;
1933                // §4 (4.0): `effects` only — `ops` and `dry_run` are retired
1934                // names the shape check above refused with their replacements.
1935                let (plan, receipt) = self.apply_effects_detailed(&repo, root.as_deref(), args)?;
1936                Ok(receipt.unwrap_or(plan))
1937            }
1938            "plan" => {
1939                let (repo, root) = self.scope(args)?;
1940                self.require_root(root.as_deref())?;
1941                let ctx = self.plan_ctx(&repo);
1942                // §11.8 (4.1): with a `select` the effects are templates over
1943                // the selector's hits; the Plan carries the `selection`.
1944                let plan = match SelectRequest::from_args(args)? {
1945                    Some(req) => {
1946                        let semantic = self.selector_semantic(&repo, req.select_str())?;
1947                        self.with_writes(root.as_deref(), |store, ds| {
1948                            select::plan_selected(store, ds, &ctx, &req, semantic)
1949                        })?
1950                    }
1951                    None => self.with_writes(root.as_deref(), |store, ds| {
1952                        let effects = select::validate_plain(store, &args["effects"])?;
1953                        Ok(store.plan_effects(&ctx, &effects, ds)?)
1954                    })?,
1955                };
1956                let mut plan = plan_json(&plan);
1957                // (4.5) `preview.diffs` — whole before/after bodies of every
1958                // touched document — only on request: `diffs: true`. The
1959                // digest is over the core, never the preview, so `plan_id`
1960                // is the same either way.
1961                if args.get("diffs").and_then(Json::as_bool) != Some(true)
1962                    && let Some(preview) = plan.get_mut("preview").and_then(Json::as_object_mut)
1963                {
1964                    preview.remove("diffs");
1965                }
1966                Ok(plan)
1967            }
1968            "execute" => {
1969                let (repo, root) = self.scope(args)?;
1970                self.require_root(root.as_deref())?;
1971                let reason = arg_str(args, "reason").map(str::to_owned);
1972                let origin = ApplyOrigin::new(&self.actor, reason.as_deref());
1973                let ctx = self.plan_ctx(&repo);
1974                // §4 (3.1): exactly one form — the plan, or `plan_id` +
1975                // `effects` (+ `select` / `expect`, 4.1: the re-plan runs the
1976                // selector again; a hit that came or went is `plan_mismatch`).
1977                let plan_form = args.get("plan").is_some();
1978                let digest_form = args.get("plan_id").is_some()
1979                    || args.get("effects").is_some()
1980                    || args.get("select").is_some()
1981                    || args.get("expect").is_some();
1982                if plan_form == digest_form {
1983                    return Err(argument_error(
1984                        name,
1985                        "pass exactly one of `plan` or `plan_id` + `effects`",
1986                        &spec.input_schema,
1987                    ));
1988                }
1989                let receipt = if plan_form {
1990                    let plan = match Plan::admit(&args["plan"]) {
1991                        Ok(plan) => plan,
1992                        Err(Admission::Refused(m)) => return Err(SurfaceError::from(m)),
1993                        Err(Admission::Invalid(m)) => {
1994                            return Err(SurfaceError::filter_invalid(m, "arguments"));
1995                        }
1996                    };
1997                    self.with_writes(root.as_deref(), |store, ds| {
1998                        Ok(store.execute_plan(&ctx, &plan, &origin, ds)?)
1999                    })?
2000                } else {
2001                    let (Some(plan_id), Some(effects)) =
2002                        (arg_str(args, "plan_id"), args.get("effects"))
2003                    else {
2004                        return Err(argument_error(
2005                            name,
2006                            "the digest form needs both `plan_id` and `effects`",
2007                            &spec.input_schema,
2008                        ));
2009                    };
2010                    let plan_id = plan_id.to_owned();
2011                    match SelectRequest::from_args(args)? {
2012                        Some(req) => {
2013                            let semantic = self.selector_semantic(&repo, req.select_str())?;
2014                            self.with_writes(root.as_deref(), |store, ds| {
2015                                store.execute_replanned(&ctx, &plan_id, &origin, ds, |store, ds| {
2016                                    select::plan_selected(store, ds, &ctx, &req, semantic)
2017                                })
2018                            })?
2019                        }
2020                        None => self.with_writes(root.as_deref(), |store, ds| {
2021                            let effects = select::validate_plain(store, effects)?;
2022                            Ok(store.execute_by_digest(&ctx, &plan_id, &effects, &origin, ds)?)
2023                        })?,
2024                    }
2025                };
2026                self.notify();
2027                Ok(receipt_json(&receipt))
2028            }
2029            // ---- the sugar tools (ADR-021 migration: `apply { effects }` sugar) ----
2030            "blocks_insert" => {
2031                let (repo, root) = self.scope(args)?;
2032                self.require_root(root.as_deref())?;
2033                let (parent, doc_id) = self.resolve_parent_ref(&repo, &arg_string(args, "to")?)?;
2034                let at = self.resolve_at(&repo, args.get("at"))?;
2035                let effects = sugar::insert(
2036                    placement_target(parent, doc_id),
2037                    at,
2038                    &arg_string(args, "markdown")?,
2039                    effect_expect(arg_parent_expect(args)?),
2040                );
2041                self.apply_sugar(&repo, root.as_deref(), effects, "blocks_insert")
2042            }
2043            "blocks_update" => {
2044                let (repo, root) = self.scope(args)?;
2045                self.require_root(root.as_deref())?;
2046                let block = self.resolve_block_ref(&repo, &arg_string(args, "_id")?)?;
2047                let markdown = arg_str(args, "markdown");
2048                let checked = arg_bool(args, "checked")?;
2049                // `set markdown` then `set _attrs.checked` (4.0: `checked` is
2050                // the one editable attribute — `attrs` is a retired name);
2051                // the caller's `expect` rides the first, a missing
2052                // `content_hash` is pinned from the live block (3.2).
2053                let effects = sugar::update_block(
2054                    &block,
2055                    markdown,
2056                    checked,
2057                    effect_expect(arg_expect(args)?),
2058                );
2059                if effects.is_empty() {
2060                    return Err(argument_error(
2061                        name,
2062                        "pass `markdown` and/or `checked`",
2063                        &spec.input_schema,
2064                    ));
2065                }
2066                self.apply_sugar(&repo, root.as_deref(), effects, "blocks_update")
2067            }
2068            "blocks_move" => {
2069                let (repo, root) = self.scope(args)?;
2070                self.require_root(root.as_deref())?;
2071                let blocks: Vec<String> =
2072                    require_some(name, "blocks", arg_strings(args, "blocks")?, spec)?
2073                        .iter()
2074                        .map(|b| self.resolve_block_ref(&repo, b))
2075                        .collect::<Result<_>>()?;
2076                let to_ref = arg_string(args, "to")?;
2077                let (parent, doc_id) = self.resolve_parent_ref(&repo, &to_ref)?;
2078                if parent == Parent::Doc {
2079                    if let Some(first) = blocks.first() {
2080                        if self.doc_id_of_block(first)? != Some(doc_id.clone()) {
2081                            return Err(SurfaceError::with_data(
2082                                "target_missing",
2083                                format!(
2084                                    "blocks_move cannot target another document's root ({to_ref}); anchor on a block in that document with at.before/at.after"
2085                                ),
2086                                json!({ "to": to_ref }),
2087                            ));
2088                        }
2089                    }
2090                }
2091                let at = self.resolve_at(&repo, args.get("at"))?;
2092                let effects = sugar::move_blocks(
2093                    blocks,
2094                    placement_target(parent, doc_id),
2095                    at,
2096                    effect_expect(arg_parent_expect(args)?),
2097                );
2098                self.apply_sugar(&repo, root.as_deref(), effects, "blocks_move")
2099            }
2100            "blocks_remove" => {
2101                let (repo, root) = self.scope(args)?;
2102                self.require_root(root.as_deref())?;
2103                let blocks: Vec<String> =
2104                    require_some(name, "blocks", arg_strings(args, "blocks")?, spec)?
2105                        .iter()
2106                        .map(|b| self.resolve_block_ref(&repo, b))
2107                        .collect::<Result<_>>()?;
2108                let effects = sugar::remove_blocks(blocks);
2109                self.apply_sugar(&repo, root.as_deref(), effects, "blocks_remove")
2110            }
2111            "blocks_split" => {
2112                let (repo, root) = self.scope(args)?;
2113                self.require_root(root.as_deref())?;
2114                let block = self.resolve_block_ref(&repo, &arg_string(args, "_id")?)?;
2115                let at: Vec<usize> = args
2116                    .get("at")
2117                    .and_then(Json::as_array)
2118                    .ok_or_else(|| bad_args("`at` must be an array of byte offsets"))?
2119                    .iter()
2120                    .map(|v| {
2121                        v.as_u64()
2122                            .map(|n| usize::try_from(n).unwrap_or(usize::MAX))
2123                            .ok_or_else(|| bad_args("`at` must be an array of byte offsets"))
2124                    })
2125                    .collect::<Result<_>>()?;
2126                let effects = sugar::split_block(&block, at);
2127                self.apply_sugar(&repo, root.as_deref(), effects, "blocks_split")
2128            }
2129            "blocks_merge" => {
2130                let (repo, root) = self.scope(args)?;
2131                self.require_root(root.as_deref())?;
2132                let blocks: Vec<String> =
2133                    require_some(name, "blocks", arg_strings(args, "blocks")?, spec)?
2134                        .iter()
2135                        .map(|b| self.resolve_block_ref(&repo, b))
2136                        .collect::<Result<_>>()?;
2137                let effects = sugar::merge_blocks(blocks, arg_str(args, "separator"));
2138                self.apply_sugar(&repo, root.as_deref(), effects, "blocks_merge")
2139            }
2140            "blocks_check" => {
2141                let (repo, root) = self.scope(args)?;
2142                self.require_root(root.as_deref())?;
2143                let blocks: Vec<String> =
2144                    require_some(name, "_ids", arg_strings(args, "_ids")?, spec)?
2145                        .iter()
2146                        .map(|b| self.resolve_block_ref(&repo, b))
2147                        .collect::<Result<_>>()?;
2148                // N × `set _attrs.checked`, each pinned by the planner; an id
2149                // named twice is one identical scalar write — the planner
2150                // drops the duplicate (a pinned 3.2 fix: the second op no
2151                // longer fails `stale_expectation`).
2152                let effects =
2153                    sugar::check_blocks(&blocks, arg_bool(args, "checked")?.unwrap_or(true));
2154                self.apply_sugar(&repo, root.as_deref(), effects, "blocks_check")
2155            }
2156            "nodes_set" => {
2157                let (repo, root) = self.scope(args)?;
2158                self.require_root(root.as_deref())?;
2159                let node_id = arg_string(args, "_id")?;
2160                let known: Option<i64> = self
2161                    .store
2162                    .conn()
2163                    .query_row(
2164                        "SELECT 1 FROM nodes WHERE node_id = ?1 AND repo_id = ?2",
2165                        params![node_id, repo],
2166                        |r| r.get(0),
2167                    )
2168                    .optional()?;
2169                if known.is_none() {
2170                    return Err(SurfaceError::with_data(
2171                        "block_missing",
2172                        format!("no node with id {}", Json::String(node_id.clone())),
2173                        json!({ "_id": node_id }),
2174                    ));
2175                }
2176                let effects = sugar::set_node(
2177                    &node_id,
2178                    &arg_string(args, "prop")?,
2179                    &arg_string(args, "value")?,
2180                );
2181                self.apply_sugar(&repo, root.as_deref(), effects, "nodes_set")
2182            }
2183            "blocks_append" => {
2184                let (repo, root) = self.scope(args)?;
2185                self.require_root(root.as_deref())?;
2186                let given = arg_string(args, "_id")?;
2187                let block = self.resolve_block_ref(&repo, &given)?;
2188                let text = arg_string(args, "text")?;
2189                // By what the block is (the reference's `blocks_append`): a heading
2190                // → the end of its section; a container → the end of its children;
2191                // a leaf → `block_not_container` (append means into).
2192                type AppendRow = (String, Option<String>, i64, Option<String>, String);
2193                let row: Option<AppendRow> = self
2194                    .store
2195                    .conn()
2196                    .query_row(
2197                        "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",
2198                        params![block],
2199                        |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)),
2200                    )
2201                    .optional()?;
2202                let Some((kind, format, children, parent, doc_path)) = row else {
2203                    return Err(block_missing(&given));
2204                };
2205                let container = if format.as_deref().unwrap_or("markdown") == "markdown" {
2206                    kind.parse::<BlockKind>().is_ok_and(|k| k.is_container())
2207                } else {
2208                    children > 0
2209                };
2210                let into = if kind == "heading" {
2211                    EffectTarget::Section { heading_id: block }
2212                } else if container {
2213                    EffectTarget::Block { id: block }
2214                } else {
2215                    return Err(SurfaceError::with_data(
2216                        "block_not_container",
2217                        format!(
2218                            "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: {} }} }}",
2219                            Json::String(block.clone())
2220                        ),
2221                        json!({
2222                            "_id": block,
2223                            "_type": kind,
2224                            // §4 "Teaching" (4.4): the sibling insert, under the
2225                            // leaf's parent (the document when top-level).
2226                            "fix": fixes::append_after(
2227                                parent.as_deref().unwrap_or(&reference_path(&doc_path)),
2228                                &block,
2229                                &text,
2230                            ),
2231                        }),
2232                    ));
2233                };
2234                let effects = sugar::insert(into, At::End, &text, None);
2235                self.apply_sugar(&repo, root.as_deref(), effects, "blocks_append")
2236            }
2237            "docs_append" => {
2238                let (repo, root) = self.scope(args)?;
2239                self.require_root(root.as_deref())?;
2240                let doc_id = self.locate_doc(&repo, name, args)?;
2241                let text = arg_string(args, "text")?;
2242                // The heading-TEXT resolution (and `ambiguous_heading`) stays
2243                // the tool's; the effect names the heading by id.
2244                let into = match arg_str(args, "section") {
2245                    Some(section) => {
2246                        let info = read::find_doc_by_id(self.store.conn(), &doc_id)?
2247                            .ok_or_else(|| block_missing(&doc_id))?;
2248                        let heading = self.resolve_section_heading(
2249                            &doc_id,
2250                            &info.path,
2251                            section,
2252                            (arg_str(args, "_id"), arg_str(args, "_path"), &text),
2253                        )?;
2254                        EffectTarget::Section {
2255                            heading_id: heading,
2256                        }
2257                    }
2258                    None => EffectTarget::Document {
2259                        id: Some(doc_id),
2260                        path: None,
2261                    },
2262                };
2263                let effects = sugar::insert(into, At::End, &text, None);
2264                self.apply_sugar(&repo, root.as_deref(), effects, "docs_append")
2265            }
2266            "links_retarget" | "links_repair" => {
2267                let (repo, root) = self.scope(args)?;
2268                self.require_root(root.as_deref())?;
2269                let repairs: Vec<omgbase_store::LinkRepair> = if name == "links_retarget" {
2270                    vec![omgbase_store::LinkRepair {
2271                        from: arg_string(args, "from_target")?,
2272                        to: arg_string(args, "to_target")?,
2273                    }]
2274                } else if let Some(list) = args.get("repairs").and_then(Json::as_array) {
2275                    list.iter()
2276                        .map(|r| {
2277                            Ok(omgbase_store::LinkRepair {
2278                                from: arg_string(r, "from")?,
2279                                to: arg_string(r, "to")?,
2280                            })
2281                        })
2282                        .collect::<Result<_>>()?
2283                } else if let (Some(f), Some(t)) =
2284                    (arg_str(args, "from_target"), arg_str(args, "to_target"))
2285                {
2286                    vec![omgbase_store::LinkRepair {
2287                        from: f.to_owned(),
2288                        to: t.to_owned(),
2289                    }]
2290                } else {
2291                    Vec::new()
2292                };
2293                if repairs.is_empty() {
2294                    return Err(SurfaceError::new(
2295                        "target_missing",
2296                        "links_repair requires `repairs` (array of {from,to}) or a `from_target`+`to_target` pair",
2297                    ));
2298                }
2299                // `hits` / `pairs` come from the store's plan as before; the
2300                // `retarget` effect re-plans the same rewrite for the commit
2301                // (4.0: committed like every other tool, no `applied`; the
2302                // preview is `plan { retarget }` → `preview.retarget`).
2303                let path_glob = arg_str(args, "path_glob").filter(|g| !g.is_empty());
2304                let plan = self.store.links_repair(&repo, &repairs, path_glob)?;
2305                let pairs: Vec<(String, String)> = repairs
2306                    .iter()
2307                    .map(|r| (r.from.clone(), r.to.clone()))
2308                    .collect();
2309                let effects = sugar::retarget(&pairs, path_glob);
2310                let out =
2311                    self.apply_sugar_with(&repo, root.as_deref(), name, |_, _| Ok(effects))?;
2312                let mut m = Map::new();
2313                m.insert(
2314                    "hits".to_owned(),
2315                    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()),
2316                );
2317                m.insert(
2318                    "pairs".to_owned(),
2319                    Json::Array(
2320                        plan.pairs
2321                            .iter()
2322                            .map(|p| json!({ "from": p.from, "to": p.to, "hits": p.hits }))
2323                            .collect(),
2324                    ),
2325                );
2326                Ok(merge(m, sugar_json(&out)))
2327            }
2328            "links_stale" => {
2329                let (repo, _) = self.scope(args)?;
2330                let glob = arg_str(args, "path_glob").filter(|g| !g.is_empty());
2331                if arg_bool(args, "summary")?.unwrap_or(false) {
2332                    return links::links_stale_summary(&self.store, &repo, glob);
2333                }
2334                links::links_stale(&self.store, &repo, glob, arg_i64(args, "limit")?)
2335            }
2336            "docs_create" => {
2337                let (repo, root) = self.scope(args)?;
2338                let fm = arg_object(args, "frontmatter")?.cloned();
2339                let sugar::Sugar { effects, reason } = sugar::create_document(
2340                    &arg_string(args, "path")?,
2341                    &arg_string(args, "markdown")?,
2342                    fm.as_ref(),
2343                );
2344                let rel = canonical(&arg_string(args, "path")?);
2345                // The library's own checks first, in its order and with its
2346                // messages (`path_taken` with an empty `data`, as pinned); the
2347                // planner's `check_absent` would name the path in `data`.
2348                let out = self.apply_sugar_with(&repo, root.as_deref(), &reason, |store, ds| {
2349                    if store.live_doc_at(&repo, &rel)? {
2350                        return Err(path_taken(format!("document already exists at {rel}")));
2351                    }
2352                    if ds.exists(&rel) {
2353                        return Err(path_taken(format!("file already exists on disk at {rel}")));
2354                    }
2355                    Ok(effects)
2356                })?;
2357                Ok(sugar_json(&out))
2358            }
2359            "docs_move" => {
2360                let (repo, root) = self.scope(args)?;
2361                let doc = self.locate_doc(&repo, name, args)?;
2362                let to_rel = canonical(&arg_string(args, "to_path")?);
2363                // spec/mutate §6 (1.3): the default is `true`; `false` opts out.
2364                let retarget = arg_bool(args, "retarget_inbound")?.unwrap_or(true);
2365                let info = self.doc_info(&repo, &doc)?;
2366                let sugar::Sugar { effects, reason } =
2367                    sugar::move_document(&info.doc_id, &info.path, &to_rel, retarget);
2368                // `dangling`/`retargeted` as the library reported them: planned
2369                // over the current blocks before anything moves; the planner
2370                // lowers the same plan.
2371                let mut report: Option<(Vec<omgbase_store::InboundLink>, Option<Retargeted>)> =
2372                    None;
2373                let out = self.apply_sugar_with(&repo, root.as_deref(), &reason, |store, ds| {
2374                    if store.live_doc_at(&repo, &to_rel)? {
2375                        return Err(path_taken(format!("a document already exists at {to_rel}")));
2376                    }
2377                    let inbound = inbound_links_to(store.conn(), &repo, &info.doc_id, &info.path)?;
2378                    if ds.exists(&to_rel) {
2379                        return Err(path_taken(format!(
2380                            "file already exists on disk at {to_rel}"
2381                        )));
2382                    }
2383                    report = Some(if retarget && !inbound.is_empty() {
2384                        let p = store.plan_inbound_retarget(
2385                            &info.doc_id,
2386                            &info.path,
2387                            &to_rel,
2388                            &inbound,
2389                        )?;
2390                        (
2391                            p.dangling,
2392                            Some(Retargeted {
2393                                blocks: p.blocks,
2394                                docs: p.docs,
2395                            }),
2396                        )
2397                    } else {
2398                        (inbound, None)
2399                    });
2400                    Ok(effects)
2401                })?;
2402                let (dangling, retargeted) = report.expect("set before the effects were built");
2403                if out.1.is_none() {
2404                    // Previewing: the Plan alone (`spec/cli` §3.6).
2405                    return Ok(sugar_json(&out));
2406                }
2407                // The Receipt, then the link report (§4, the reference's key
2408                // order): `retargeted`, then each dangling link as a link row
2409                // under the `links` target's names (§1.2, 5.0) with its source
2410                // `_path` rooted; its `target` stays as authored — that is what
2411                // the link says.
2412                let mut m = Map::new();
2413                m.insert(
2414                    "retargeted".to_owned(),
2415                    retargeted_json(retargeted.as_ref()),
2416                );
2417                m.insert(
2418                    "dangling".to_owned(),
2419                    Json::Array(dangling.iter().map(dangling_json).collect()),
2420                );
2421                let Json::Object(receipt) = sugar_json(&out) else {
2422                    unreachable!("a receipt is an object")
2423                };
2424                Ok(merge(receipt, Json::Object(m)))
2425            }
2426            "docs_delete" => {
2427                let (repo, root) = self.scope(args)?;
2428                let doc = self.locate_doc(&repo, name, args)?;
2429                let info = self.doc_info(&repo, &doc)?;
2430                let sugar::Sugar { effects, reason } =
2431                    sugar::delete_document(&info.doc_id, &info.path);
2432                self.apply_sugar(&repo, root.as_deref(), effects, &reason)
2433            }
2434            "docs_set_meta" => {
2435                let (repo, root) = self.scope(args)?;
2436                let doc = self.locate_doc(&repo, name, args)?;
2437                let set = arg_object(args, "set")?.cloned();
2438                let unset = if args.get("unset").is_some() {
2439                    arg_strings(args, "unset")?
2440                } else {
2441                    Vec::new()
2442                };
2443                let info = self.doc_info(&repo, &doc)?;
2444                // One `set`/`unset _frontmatter.<key>` per key, `unset` winning
2445                // a key named in both (`sugar::set_meta`).
2446                let sugar::Sugar { effects, reason } =
2447                    sugar::set_meta(&info.doc_id, &info.path, set.as_ref(), &unset);
2448                if effects.is_empty() {
2449                    // Nothing to set or unset: the library path rewrote the
2450                    // frontmatter through the YAML emitter anyway (a
2451                    // normalizing no-op commit); with no effect to plan this is
2452                    // an argument error in both engines (3.3).
2453                    return Err(argument_error(
2454                        name,
2455                        "pass `set` and/or `unset`",
2456                        &spec.input_schema,
2457                    ));
2458                }
2459                self.apply_sugar(&repo, root.as_deref(), effects, &reason)
2460            }
2461            "docs_update" => {
2462                // Surface 4.0: sugar for `set { document, field: "content" }`
2463                // — the whole-document planner's opset lowered into one plan
2464                // and committed as one receipt (the identity decision is the
2465                // Plan's `preview.identity`; preview with `plan`). A content
2466                // already current plans nothing and commits nothing.
2467                let (repo, root) = self.scope(args)?;
2468                self.require_root(root.as_deref())?;
2469                let doc = self.locate_doc(&repo, name, args)?;
2470                let info = self.doc_info(&repo, &doc)?;
2471                let content = arg_string(args, "content")?;
2472                let reason = arg_str(args, "reason").map(str::to_owned);
2473                let origin = ApplyOrigin::new(&self.actor, reason.as_deref());
2474                let ctx = self.plan_ctx(&repo);
2475                let effects = sugar::update_content(&info.doc_id, &content);
2476                let preview = self.preview;
2477                let (plan, receipt) = self.with_writes(root.as_deref(), |store, ds| {
2478                    if preview {
2479                        return Ok((store.plan_effects(&ctx, &effects, ds)?, None));
2480                    }
2481                    let (plan, receipt) = store
2482                        .apply_effects_detailed(&ctx, &effects, &origin, ds)
2483                        .map_err(strip_effect_index)?;
2484                    Ok((plan, Some(receipt)))
2485                })?;
2486                self.last_plan = Some(plan.clone());
2487                if receipt.as_ref().is_some_and(|r| r.commit.is_some()) {
2488                    self.notify();
2489                }
2490                Ok(sugar_json(&(plan, receipt)))
2491            }
2492            "observe" => {
2493                let (repo, _) = self.scope(args)?;
2494                let path = storage_path(&arg_string(args, "path")?).to_owned();
2495                let content = arg_string(args, "content")?;
2496                let ts = self.now();
2497                let config = self.config.clone();
2498                let out = self
2499                    .store
2500                    .observe_one(&repo, &path, &content, &ts, &config)?;
2501                self.store.sweep_pool(&ts)?;
2502                self.notify();
2503                Ok(observe_json(&out))
2504            }
2505            "observe_many" => {
2506                let (repo, _) = self.scope(args)?;
2507                let files = args
2508                    .get("files")
2509                    .and_then(Json::as_array)
2510                    .ok_or_else(|| bad_args("`files` must be an array of {path, content}"))?;
2511                let items: Vec<omgbase_store::BatchItem> = files
2512                    .iter()
2513                    .map(|f| {
2514                        Ok(omgbase_store::BatchItem::observed(
2515                            storage_path(&arg_string(f, "path")?),
2516                            &arg_string(f, "content")?,
2517                        ))
2518                    })
2519                    .collect::<Result<_>>()?;
2520                let ts = self.now();
2521                let config = self.config.clone();
2522                let outcomes = self.store.observe_batch(&repo, &items, &ts, &config)?;
2523                self.store.sweep_pool(&ts)?;
2524                self.notify();
2525                let mut out = Vec::with_capacity(outcomes.len());
2526                for o in &outcomes {
2527                    match o.as_observed() {
2528                        Some(obs) => out.push(observe_json(obs)),
2529                        None => {
2530                            return Err(SurfaceError::other(format!(
2531                                "observe_many: unexpected outcome for {}",
2532                                o.path()
2533                            )));
2534                        }
2535                    }
2536                }
2537                Ok(Json::Array(out))
2538            }
2539            "observe_delete" => {
2540                let (repo, _) = self.scope(args)?;
2541                let path = storage_path(&arg_string(args, "path")?).to_owned();
2542                let ts = self.now();
2543                let out = self.store.observe_delete(&repo, &path, &ts)?;
2544                self.notify();
2545                Ok(
2546                    json!({ "_id": out.doc_id, "_path": reference_path(&out.path), "deleted": out.deleted() }),
2547                )
2548            }
2549            "blocks_history" => history::blocks_history(
2550                &self.store,
2551                &arg_string(args, "_id")?,
2552                arg_i64(args, "limit")?,
2553            ),
2554            "docs_diff" => {
2555                let (repo, _) = self.scope(args)?;
2556                let doc_id = self.locate_doc(&repo, name, args)?;
2557                history::diff_blocks(
2558                    &self.store,
2559                    &doc_id,
2560                    &arg_string(args, "from_rev")?,
2561                    &arg_string(args, "to_rev")?,
2562                )
2563            }
2564            "docs_diff_unified" => {
2565                let (repo, _) = self.scope(args)?;
2566                let doc_ref = arg_str(args, "_id")
2567                    .or(arg_str(args, "_path"))
2568                    .unwrap_or_default()
2569                    .to_owned();
2570                let doc_id = self.locate_doc(&repo, name, args)?;
2571                let revs = history::recent_revs(self.store.conn(), &doc_id)?;
2572                let to_rev = arg_str(args, "to_rev")
2573                    .map(str::to_owned)
2574                    .or_else(|| revs.first().cloned());
2575                let from_rev = arg_str(args, "from_rev")
2576                    .map(str::to_owned)
2577                    .or_else(|| revs.get(1).cloned())
2578                    .or_else(|| revs.first().cloned());
2579                let (Some(from), Some(to)) = (from_rev, to_rev) else {
2580                    return Err(SurfaceError::new(
2581                        "target_missing",
2582                        format!("no revisions to diff for {}", Json::String(doc_ref)),
2583                    ));
2584                };
2585                let path: Option<String> = self
2586                    .store
2587                    .conn()
2588                    .query_row(
2589                        "SELECT path FROM docs WHERE doc_id = ?1",
2590                        params![doc_id],
2591                        |r| r.get(0),
2592                    )
2593                    .optional()?;
2594                let diff = history::diff_unified_text(&self.store, &doc_id, &from, &to)?;
2595                Ok(
2596                    json!({ "_id": doc_id, "_path": path.as_deref().map_or_else(String::new, reference_path), "from": from, "to": to, "diff": diff }),
2597                )
2598            }
2599            // sugar for `docs_read { _id | _path, at: rev }` (4.7): the same code
2600            // path, the identical result.
2601            "docs_read_at" => {
2602                let (repo, _) = self.scope(args)?;
2603                let doc_id = self.locate_doc(&repo, name, args)?;
2604                let rev = arg_string(args, "rev")?;
2605                self.docs_read_at(&doc_id, &rev)
2606            }
2607            "docs_history" => {
2608                let (repo, _) = self.scope(args)?;
2609                let glob = arg_str(args, "path_glob").filter(|g| !g.is_empty());
2610                let id = arg_str(args, "_id");
2611                let path = arg_str(args, "_path");
2612                if glob.is_none() && id.is_none() && path.is_none() {
2613                    return Err(SurfaceError::new(
2614                        "target_missing",
2615                        "docs_history requires one of path_glob, _id or _path",
2616                    ));
2617                }
2618                let include_deleted = arg_bool(args, "include_deleted")?.unwrap_or(false);
2619                // A live document resolves through the locator; with
2620                // include_deleted a tombstoned one is looked up by the same attribute.
2621                let doc: Option<String> = if id.is_some() || path.is_some() {
2622                    let tomb: Option<String> = if include_deleted {
2623                        match (id, path) {
2624                            (Some(i), _) => self
2625                                .store
2626                                .conn()
2627                                .query_row(
2628                                    "SELECT doc_id FROM docs WHERE doc_id = ?1 AND repo_id = ?2",
2629                                    params![i, repo],
2630                                    |r| r.get(0),
2631                                )
2632                                .optional()?,
2633                            (None, Some(p)) => self
2634                                .store
2635                                .conn()
2636                                .query_row(
2637                                    "SELECT doc_id FROM docs WHERE repo_id = ?1 AND path = ?2",
2638                                    params![repo, storage_path(p)],
2639                                    |r| r.get(0),
2640                                )
2641                                .optional()?,
2642                            (None, None) => None,
2643                        }
2644                    } else {
2645                        None
2646                    };
2647                    Some(match tomb {
2648                        Some(d) => d,
2649                        None => self.locate_doc(&repo, name, args)?,
2650                    })
2651                } else {
2652                    None
2653                };
2654                history::docs_history(
2655                    &self.store,
2656                    &repo,
2657                    glob,
2658                    doc.as_deref(),
2659                    include_deleted,
2660                    arg_i64(args, "limit")?,
2661                )
2662            }
2663            "changes_since" => {
2664                let (repo, _) = self.scope(args)?;
2665                let page = self.store.changes_since(
2666                    &repo,
2667                    arg_i64(args, "cursor")?.unwrap_or(0),
2668                    arg_usize(args, "limit")?.unwrap_or(50),
2669                    arg_str(args, "origin").filter(|o| !o.is_empty()),
2670                )?;
2671                Ok(root_changes_page(page.to_json()))
2672            }
2673            "repos_status" => {
2674                let (repo, root) = self.scope(args)?;
2675                let fs = RealFileSystem;
2676                let disk = root
2677                    .as_deref()
2678                    .map(|r| (&fs as &dyn omgbase_sync::FileSystem, Path::new(r)));
2679                Ok(omgbase_sync::repos_status(&self.store, &repo, disk)?.to_json())
2680            }
2681            "sync_status" => {
2682                let (repo, root) = self.scope(args)?;
2683                let fs = RealFileSystem;
2684                let disk = root
2685                    .as_deref()
2686                    .map(|r| (&fs as &dyn omgbase_sync::FileSystem, Path::new(r)));
2687                Ok(omgbase_sync::sync_status(&self.store, &repo, disk)?.to_json())
2688            }
2689            "repos" => {
2690                let rows = self.repo_rows()?;
2691                Ok(json!({
2692                    "repos": rows.iter().map(|r| json!({ "slug": r.slug, "hasSource": r.root_path.is_some() })).collect::<Vec<_>>(),
2693                }))
2694            }
2695            // §4 "Profiles": the tool reports which profile the host serves.
2696            "version" => version_info(Some(&self.store), &self.build_info, self.profile),
2697            other => Err(SurfaceError::other(format!("unknown tool {other}"))),
2698        }
2699    }
2700}
2701
2702/// `block_missing` for a block the caller named (`_id` or a ref): the
2703/// attribute spelling, the argument as given in `data`.
2704fn block_missing(value: &str) -> SurfaceError {
2705    SurfaceError::with_data(
2706        "block_missing",
2707        format!("no block with id {}", Json::String(value.to_owned())),
2708        json!({ "_id": value }),
2709    )
2710}
2711
2712/// `nodes_block_read` on a node that names no live block.
2713fn node_has_no_block(node_id: &str, doc_id: &str) -> SurfaceError {
2714    SurfaceError::with_data(
2715        "block_missing",
2716        format!("node {} has no block", Json::String(node_id.to_owned())),
2717        json!({ "_id": node_id, "_doc_id": doc_id }),
2718    )
2719}
2720
2721/// The message every tool uses for a `semantic(...)` call with no provider.
2722pub 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)";
2723
2724/// `provider_unavailable` with no provider configured (§4, 3.0).
2725#[must_use]
2726pub fn provider_unavailable() -> SurfaceError {
2727    SurfaceError::with_data(
2728        "provider_unavailable",
2729        PROVIDER_UNAVAILABLE_MESSAGE,
2730        json!({ "provider": null }),
2731    )
2732}
2733
2734/// The `effects` argument (`spec/mutate` §11.1): a malformed effect is
2735/// `filter_invalid { reason, hint: "arguments" }` naming `effects[i]`.
2736fn parse_effects(v: &Json) -> Result<Vec<PublicEffect>> {
2737    PublicEffect::list_from_json(v).map_err(|m| SurfaceError::filter_invalid(m, "arguments"))
2738}
2739
2740/// A plan on the wire (`spec/mutate` §11): the digest-covered core as the
2741/// planner wrote it — a client echoes it back to `execute`, so no key moves
2742/// — and, under §1 "Paths", the preview's `revisions[]._path` and `diffs`
2743/// keys in the reference form.
2744#[must_use]
2745pub fn plan_json(plan: &Plan) -> Json {
2746    let mut v = plan.to_json(true);
2747    if let Some(preview) = v.get_mut("preview").and_then(Json::as_object_mut) {
2748        if let Some(revs) = preview.get_mut("revisions").and_then(Json::as_array_mut) {
2749            for r in revs {
2750                root_field(r, "_path");
2751            }
2752        }
2753        // The kernel results spell `merged_into` (the `spec/mutate` fixture
2754        // spelling); on the wire a result is `{ ids, removed?, mergedInto? }`,
2755        // as the reference's `OpResult` is — the 3.x apply result's spelling.
2756        if let Some(results) = preview.get_mut("results").and_then(Json::as_array_mut) {
2757            for r in results {
2758                if let Some(o) = r.as_object_mut() {
2759                    if let Some(m) = o.shift_remove("merged_into") {
2760                        o.insert("mergedInto".to_owned(), m);
2761                    }
2762                }
2763            }
2764        }
2765        if let Some(diffs) = preview.get_mut("diffs") {
2766            *diffs = reference_keyed(diffs);
2767        }
2768        // 4.0: a `retarget` effect's hits name their document's path.
2769        if let Some(retargets) = preview.get_mut("retarget").and_then(Json::as_array_mut) {
2770            for r in retargets {
2771                if let Some(hits) = r.get_mut("hits").and_then(Json::as_array_mut) {
2772                    for h in hits {
2773                        root_field(h, "_path");
2774                    }
2775                }
2776            }
2777        }
2778    }
2779    v
2780}
2781
2782/// A receipt on the wire: `revisions[]._path` in the reference form.
2783#[must_use]
2784pub fn receipt_json(receipt: &Receipt) -> Json {
2785    let mut v = receipt.to_json();
2786    if let Some(revs) = v.get_mut("revisions").and_then(Json::as_array_mut) {
2787        for r in revs {
2788            root_field(r, "_path");
2789        }
2790    }
2791    v
2792}
2793
2794/// A dangling inbound link as a link row (`spec/surface` §4 `docs_move`, 5.0):
2795/// `{ _doc_id, _path, _block_id, target, _anchor, _field? }` — the source
2796/// `_path` rooted, the `target` (a role) as authored.
2797fn dangling_json(l: &omgbase_store::InboundLink) -> Json {
2798    let mut m = Map::new();
2799    m.insert("_doc_id".to_owned(), json!(l.doc));
2800    m.insert("_path".to_owned(), json!(reference_path(&l.path)));
2801    m.insert("_block_id".to_owned(), json!(l.block));
2802    m.insert("target".to_owned(), json!(l.target));
2803    m.insert("_anchor".to_owned(), json!(l.anchor));
2804    if let Some(f) = &l.field {
2805        m.insert("_field".to_owned(), json!(f));
2806    }
2807    Json::Object(m)
2808}
2809
2810/// `path_taken` as the library's document operations raise it: the message
2811/// alone, an empty `data` (`spec/surface` §7 pins `path_taken {}`).
2812fn path_taken(message: String) -> SurfaceError {
2813    SurfaceError::from(MutationError::new(ErrorCode::PathTaken, message))
2814}
2815
2816/// `changes_since` on the wire (§1 Paths, 2.0): the store renders its digests
2817/// in the storage form; every revision `_path` is rooted here, and so is the
2818/// summary's subject — the reference composes `renderSummary` from rooted
2819/// paths, so the same line reads `observed: /a.md — 3 inserted`,
2820/// `api(human:spec): deleted /a.md`, `api(human:spec): moved /a.md → /b.md`,
2821/// `api(human:spec): moved /a.md → /b.md; /index.md — 31 edited` (a move
2822/// with its retarget, mutate 2.1). The subject is the text between the first
2823/// `: ` and the ` — ` detail (or the end): a comma-joined path list,
2824/// `deleted <paths>`, `moved <from> → <to>` or `moved <from> → <to>; <paths>`.
2825fn root_changes_page(mut page: Json) -> Json {
2826    let Some(digests) = page.get_mut("digests").and_then(Json::as_array_mut) else {
2827        return page;
2828    };
2829    for d in digests.iter_mut() {
2830        if let Some(revs) = d.get_mut("revisions").and_then(Json::as_array_mut) {
2831            for r in revs.iter_mut() {
2832                root_field(r, "_path");
2833            }
2834        }
2835        if let Some(summary) = d.get("summary").and_then(Json::as_str) {
2836            let rooted = root_summary(summary);
2837            d["summary"] = Json::String(rooted);
2838        }
2839    }
2840    page
2841}
2842
2843/// The summary's subject paths in the reference form (see [`root_changes_page`]).
2844fn root_summary(summary: &str) -> String {
2845    let Some(colon) = summary.find(": ") else {
2846        return summary.to_owned();
2847    };
2848    let (head, rest) = summary.split_at(colon + 2);
2849    let (subject, detail) = match rest.find(" — ") {
2850        Some(i) => rest.split_at(i),
2851        None => (rest, ""),
2852    };
2853    let root_list = |s: &str| -> String {
2854        if s.is_empty() {
2855            return String::new();
2856        }
2857        s.split(", ")
2858            .map(reference_path)
2859            .collect::<Vec<_>>()
2860            .join(", ")
2861    };
2862    let subject = if let Some(paths) = subject.strip_prefix("deleted ") {
2863        format!("deleted {}", root_list(paths))
2864    } else if let Some(mv) = subject.strip_prefix("moved ") {
2865        // `moved <from> → <to>`, with the retarget's sources after a `; `.
2866        let (mv, rest) = match mv.split_once("; ") {
2867            Some((mv, rest)) => (mv, Some(rest)),
2868            None => (mv, None),
2869        };
2870        let moved = match mv.split_once(" → ") {
2871            Some((from, to)) => format!("moved {} → {}", reference_path(from), reference_path(to)),
2872            None => format!("moved {mv}"),
2873        };
2874        match rest {
2875            Some(rest) => format!("{moved}; {}", root_list(rest)),
2876            None => moved,
2877        }
2878    } else {
2879        root_list(subject)
2880    };
2881    format!("{head}{subject}{detail}")
2882}
2883
2884/// `spec/search` §4's evidence on the wire: `{ rrf, boosts, ftsRank?,
2885/// vectorRank?, cosine? }`.
2886fn evidence_json(e: &omgbase_store::Evidence) -> Json {
2887    let mut m = Map::new();
2888    m.insert("rrf".to_owned(), json!(e.rrf));
2889    m.insert("boosts".to_owned(), e.boosts.to_json());
2890    if let Some(r) = e.fts_rank {
2891        m.insert("ftsRank".to_owned(), json!(r));
2892    }
2893    if let Some(r) = e.vector_rank {
2894        m.insert("vectorRank".to_owned(), json!(r));
2895        if let Some(c) = e.cosine {
2896            m.insert("cosine".to_owned(), json!(c));
2897        }
2898    }
2899    Json::Object(m)
2900}
2901
2902/// The public `observe` result: `{ _id, _path, rev, commitId, converged,
2903/// echo, conflicted, dispositions: [{ kind, count }] }` — the store's own
2904/// `{ doc, commit, rev }` outcome (`spec/store` §9) re-shaped under the row
2905/// rule (§1.2, 5.0).
2906fn observe_json(o: &omgbase_store::ObserveOutcome) -> Json {
2907    json!({
2908        "_id": o.doc_id,
2909        "_path": reference_path(&o.path),
2910        "rev": o.rev,
2911        "commitId": o.commit_id,
2912        "converged": o.converged,
2913        "echo": o.echo,
2914        "conflicted": o.conflicted,
2915        "dispositions": o.dispositions.iter().map(|(k, n)| json!({ "kind": k, "count": n })).collect::<Vec<_>>(),
2916    })
2917}
2918
2919#[cfg(test)]
2920mod sugar_tests;
2921
2922#[cfg(test)]
2923mod tests {
2924    use super::*;
2925    use omgbase_store::{MemDocStore, SequentialMinter};
2926
2927    fn surface() -> Surface {
2928        let mut store =
2929            Store::open_in_memory_with_minter(Box::new(SequentialMinter::new())).unwrap();
2930        let repo = store.create_repo("fixture").unwrap();
2931        let mut n = 0;
2932        Surface::new(store, &repo, None)
2933            .with_doc_store(Box::new(MemDocStore::new()))
2934            .with_clock(move || {
2935                n += 1;
2936                format!("2026-09-26T10:{n:02}:00.000Z")
2937            })
2938    }
2939
2940    #[test]
2941    fn catalog_lists_every_tool_of_the_table() {
2942        let names: Vec<&str> = tools().iter().map(|t| t.name).collect();
2943        for want in [
2944            "docs_outline",
2945            "docs_read",
2946            "docs_read_many",
2947            "blocks_read",
2948            "nodes_block_read",
2949            "blocks_read_many",
2950            "docs_tree",
2951            "docs_list",
2952            "query_syntax",
2953            "query",
2954            "graph",
2955            "blocks_search",
2956            "blocks_resolve",
2957            "apply",
2958            "plan",
2959            "execute",
2960            "blocks_insert",
2961            "blocks_update",
2962            "blocks_move",
2963            "blocks_remove",
2964            "blocks_split",
2965            "blocks_merge",
2966            "blocks_check",
2967            "nodes_set",
2968            "blocks_append",
2969            "docs_append",
2970            "links_retarget",
2971            "links_stale",
2972            "links_repair",
2973            "docs_create",
2974            "docs_move",
2975            "docs_delete",
2976            "docs_set_meta",
2977            "docs_update",
2978            "observe",
2979            "observe_many",
2980            "observe_delete",
2981            "blocks_history",
2982            "docs_diff",
2983            "docs_diff_unified",
2984            "docs_read_at",
2985            "docs_history",
2986            "changes_since",
2987            "repos_status",
2988            "sync_status",
2989            "repos",
2990            "version",
2991        ] {
2992            assert!(names.contains(&want), "missing {want}");
2993        }
2994        assert_eq!(names.len(), 47);
2995        for t in tools() {
2996            assert_eq!(t.input_schema["type"], "object");
2997        }
2998    }
2999
3000    /// §4 "Profiles" (4.3): `minimal` lists exactly the ten in catalog
3001    /// order; `full` the whole table; a profile filters listing and calling
3002    /// only — the schemas, the write set and the dispatch stay the table's.
3003    #[test]
3004    fn profiles_filter_the_listing_not_the_semantics() {
3005        let full = surface();
3006        assert_eq!(full.profile(), Profile::Full);
3007        assert_eq!(full.tools(), tools());
3008        let minimal = surface().with_profile(Profile::Minimal);
3009        assert_eq!(minimal.profile(), Profile::Minimal);
3010        let names: Vec<&str> = minimal.tools().iter().map(|t| t.name).collect();
3011        // The ten, in the table's registration order (not the list's).
3012        let mut sorted_names = names.clone();
3013        sorted_names.sort_unstable();
3014        let mut sorted_set = MINIMAL_TOOLS.to_vec();
3015        sorted_set.sort_unstable();
3016        assert_eq!(sorted_names, sorted_set);
3017        assert_eq!(
3018            names,
3019            [
3020                "docs_read",
3021                "docs_tree",
3022                "query_syntax",
3023                "query",
3024                "blocks_resolve",
3025                "apply",
3026                "plan",
3027                "execute",
3028                "changes_since",
3029                "version",
3030            ]
3031        );
3032        for t in minimal.tools() {
3033            assert_eq!(
3034                Some(&t),
3035                tool_spec(t.name),
3036                "{} is the full table's spec",
3037                t.name
3038            );
3039        }
3040        assert!(
3041            Surface::is_write_tool("docs_create"),
3042            "the write set is static"
3043        );
3044        assert_eq!("full".parse::<Profile>(), Ok(Profile::Full));
3045        assert_eq!("minimal".parse::<Profile>(), Ok(Profile::Minimal));
3046        assert_eq!(
3047            "lite".parse::<Profile>(),
3048            Err("unknown tools profile \"lite\" (expected full|minimal)".to_owned())
3049        );
3050        assert_eq!(Profile::Minimal.to_string(), "minimal");
3051        // `version` reports the profile; a tool the profile hides still
3052        // dispatches at the library level (the host's membership check is the
3053        // gate: `tools/call` on an unlisted name is `unknown tool <name>`).
3054        let mut minimal = minimal;
3055        let v = minimal.call("version", json!({}));
3056        assert_eq!(v.body["profile"], "minimal");
3057        let mut full = full;
3058        assert_eq!(full.call("version", json!({})).body["profile"], "full");
3059        let listed = minimal.call("docs_list", json!({}));
3060        assert!(!listed.is_error);
3061        let unknown = minimal.call("docs_lits", json!({}));
3062        assert!(unknown.is_error);
3063        assert_eq!(unknown.body["message"], "unknown tool docs_lits");
3064    }
3065
3066    /// `spec/mutate` §11.8 at the surface (4.1): `plan` / `apply` / `execute`
3067    /// take `select` + `expect`; the Plan carries the digested `selection`;
3068    /// the digest form re-runs the selector; a one-shot `apply` needs
3069    /// `expect.max`; `expect` without `select` is refused; zero hits commit
3070    /// nothing; the refusals carry `hint: "selection"`.
3071    #[test]
3072    fn plan_apply_and_execute_take_a_selector() {
3073        let mut s = surface();
3074        for (path, content) in [
3075            ("hub.md", "---\ntype: project\n---\n\n# Hub\n\nIntro.\n"),
3076            (
3077                "ws/alpha.md",
3078                "---\ntype: workstream\nstatus: completed\nproject: hub.md\n---\n\n# Alpha\n",
3079            ),
3080            (
3081                "ws/beta.md",
3082                "---\ntype: workstream\nstatus: completed\nproject: hub.md\n---\n\n# Beta\n",
3083            ),
3084            (
3085                "ws/gamma.md",
3086                "---\ntype: workstream\nstatus: active\nproject: hub.md\n---\n\n# Gamma\n",
3087            ),
3088        ] {
3089            let out = s.call("observe", json!({ "path": path, "content": content }));
3090            assert!(!out.is_error, "{}", out.body);
3091        }
3092        let select = "from docs where type == \"workstream\" and status == \"completed\"";
3093        let templates = json!([
3094            { "op": "insert", "into": { "kind": "document", "_path": { "oqx": "project" } }, "markdown": { "oqx": "\"- [\" + _title + \"](\" + _path + \")\"" } },
3095            { "op": "set", "field": "_frontmatter.rolled_up", "value": true },
3096        ]);
3097        let plan = s.call(
3098            "plan",
3099            json!({ "select": select, "effects": templates, "diffs": true }),
3100        );
3101        assert!(!plan.is_error, "{}", plan.body);
3102        let plan = plan.body;
3103        assert_eq!(plan["planner_v"], "p3");
3104        assert_eq!(plan["effects"].as_array().map(Vec::len), Some(4));
3105        assert_eq!(
3106            plan["effects"][0]["into"],
3107            json!({ "kind": "document", "_id": "d_0", "_path": "hub.md" })
3108        );
3109        assert_eq!(plan["effects"][0]["markdown"], "- [Alpha](/ws/alpha.md)");
3110        assert_eq!(
3111            plan["effects"][1]["target"],
3112            json!({ "kind": "document", "_id": "d_1", "_path": "ws/alpha.md" })
3113        );
3114        assert_eq!(
3115            plan["selection"],
3116            json!({ "query": select, "templates": templates, "cardinality": 2, "identities": ["d_1", "d_2"] })
3117        );
3118        assert!(
3119            plan["preview"]["diffs"]["/hub.md"]["after"]
3120                .as_str()
3121                .unwrap()
3122                .contains("- [Beta](/ws/beta.md)")
3123        );
3124        // the plan form carries its selection; the receipt commits all three documents
3125        let receipt = s.call(
3126            "execute",
3127            json!({ "plan": plan.clone(), "reason": "rollup" }),
3128        );
3129        assert!(!receipt.is_error, "{}", receipt.body);
3130        assert_eq!(receipt.body["plan_id"], plan["plan_id"]);
3131        assert_eq!(receipt.body["revisions"].as_array().map(Vec::len), Some(3));
3132        let ops: String = s
3133            .store()
3134            .conn()
3135            .query_row(
3136                "SELECT ops FROM commits WHERE commit_id = ?1",
3137                [receipt.body["commit"].as_str().unwrap()],
3138                |r| r.get(0),
3139            )
3140            .unwrap();
3141        assert!(ops.contains("\"selection\":{\"cardinality\":2"), "{ops}");
3142        // the digest form re-runs the selector: the marked hits no longer match (the
3143        // selector is the same but `rolled_up` is not in it, so they still do — a new
3144        // completed workstream changes the identities → plan_mismatch)
3145        let again = s.call(
3146            "plan",
3147            json!({ "select": select, "effects": templates, "expect": { "max": 5 } }),
3148        );
3149        assert!(!again.is_error, "{}", again.body);
3150        assert_eq!(again.body["selection"]["expect"], json!({ "max": 5 }));
3151        let out = s.call("observe", json!({ "path": "ws/delta.md", "content": "---\ntype: workstream\nstatus: completed\nproject: hub.md\n---\n\n# Delta\n" }));
3152        assert!(!out.is_error);
3153        let stale = s.call("execute", json!({ "plan_id": again.body["plan_id"], "select": select, "effects": templates, "expect": { "max": 5 } }));
3154        assert!(stale.is_error);
3155        assert_eq!(stale.body["error"], "plan_mismatch", "{}", stale.body);
3156        assert_eq!(stale.body["data"]["expected"], again.body["plan_id"]);
3157        // a one-shot apply needs the bound; too small a bound is selection_too_large and writes nothing
3158        let unbounded = s.call("apply", json!({ "select": select, "effects": templates }));
3159        assert!(unbounded.is_error);
3160        assert_eq!(unbounded.body["error"], "filter_invalid");
3161        assert_eq!(unbounded.body["data"]["hint"], "selection");
3162        assert_eq!(
3163            unbounded.body["message"],
3164            "`expect.max` is required on `apply` with `select` — bound the selection, or preview it with `plan`"
3165        );
3166        let small = s.call(
3167            "apply",
3168            json!({ "select": select, "effects": templates, "expect": { "max": 2 } }),
3169        );
3170        assert!(small.is_error);
3171        assert_eq!(small.body["error"], "selection_too_large", "{}", small.body);
3172        // three hits under `max: 2`: the page is `bound + 1`, so the count is
3173        // exact — and the fix (4.4) is the same call bounded at it.
3174        assert_eq!(
3175            small.body["data"],
3176            json!({
3177                "cardinality": 3,
3178                "max": 2,
3179                "fix": omgbase_mutate::canonical_json(&json!({ "select": select, "effects": templates, "expect": { "max": 3 } })),
3180            })
3181        );
3182        let one = s.call(
3183            "apply",
3184            json!({ "select": select, "effects": templates, "expect": { "max": 1 } }),
3185        );
3186        // the page was cut at the bound: the count for the fix comes from a
3187        // second, unbounded run — the same three.
3188        assert_eq!(
3189            one.body["data"],
3190            json!({
3191                "cardinality": ">1",
3192                "max": 1,
3193                "fix": omgbase_mutate::canonical_json(&json!({ "select": select, "effects": templates, "expect": { "max": 3 } })),
3194            }),
3195            "{}",
3196            one.body
3197        );
3198        // zero hits: an empty plan, a receipt with commit null
3199        let none = s.call("apply", json!({ "select": "from docs where type == \"nope\"", "effects": templates, "expect": { "max": 10 } }));
3200        assert!(!none.is_error, "{}", none.body);
3201        assert_eq!(none.body["commit"], Json::Null);
3202        assert_eq!(none.body["revisions"], json!([]));
3203        let empty = s.call(
3204            "plan",
3205            json!({ "select": "from docs where type == \"nope\"", "effects": templates }),
3206        );
3207        assert_eq!(empty.body["effects"], json!([]));
3208        assert_eq!(empty.body["selection"]["cardinality"], 0);
3209        // `expect` without `select`; a refused selector; the argument hint tail names both
3210        let e = s.call(
3211            "plan",
3212            json!({ "effects": templates, "expect": { "max": 1 } }),
3213        );
3214        assert_eq!(
3215            e.body["message"],
3216            "`expect` bounds a selection — pass `select` with it"
3217        );
3218        let e = s.call(
3219            "plan",
3220            json!({ "select": "from links", "effects": templates }),
3221        );
3222        assert_eq!(e.body["error"], "filter_invalid");
3223        assert_eq!(e.body["data"]["hint"], "selection");
3224        assert!(
3225            e.body["message"]
3226                .as_str()
3227                .unwrap()
3228                .starts_with("select: `links` rows")
3229        );
3230        let e = s.call("execute", json!({ "plan": plan, "select": select }));
3231        assert!(
3232            e.body["message"]
3233                .as_str()
3234                .unwrap()
3235                .starts_with("pass exactly one of `plan` or `plan_id` + `effects`"),
3236            "{}",
3237            e.body
3238        );
3239        let e = s.call(
3240            "plan",
3241            json!({ "select": select, "effects": templates, "expect": 3 }),
3242        );
3243        assert!(
3244            e.body["message"]
3245                .as_str()
3246                .unwrap()
3247                .starts_with("`expect` must be"),
3248            "{}",
3249            e.body
3250        );
3251    }
3252
3253    /// `spec/mutate` §11 at the surface: `plan` returns the frozen plan with
3254    /// a rooted preview, `execute` commits it (receipt with rooted paths),
3255    /// `apply` takes `ops` or `effects` (exactly one) and `effects` +
3256    /// `dry_run` is the plan; `execute` needs a plan or a digest + effects.
3257    #[test]
3258    fn plan_execute_and_apply_effects_round_trip() {
3259        let mut s = surface();
3260        let out = s.call(
3261            "observe",
3262            json!({ "path": "a.md", "content": "---\nstatus: open\n---\n\n# A\n\nAlpha.\n" }),
3263        );
3264        assert!(!out.is_error, "{}", out.body);
3265        let effects = json!([
3266            { "op": "set", "target": { "kind": "document", "_path": "/a.md" }, "field": "_frontmatter.status", "value": "done" },
3267            { "op": "insert", "into": { "kind": "section", "heading_id": "b_0" }, "markdown": "Tail." },
3268        ]);
3269        let plan = s.call("plan", json!({ "effects": effects, "diffs": true }));
3270        assert!(!plan.is_error, "{}", plan.body);
3271        let plan = plan.body;
3272        assert_eq!(plan["version"], 1);
3273        assert_eq!(plan["plan_id"].as_str().map(str::len), Some(64));
3274        assert_eq!(
3275            plan["effects"][0]["target"],
3276            json!({ "kind": "document", "_id": "d_0", "_path": "a.md" }),
3277            "the core keeps storage paths"
3278        );
3279        assert_eq!(
3280            plan["preview"]["revisions"][0]["_path"], "/a.md",
3281            "the preview is rooted"
3282        );
3283        assert!(
3284            plan["preview"]["diffs"].get("/a.md").is_some(),
3285            "{}",
3286            plan["preview"]["diffs"]
3287        );
3288        assert_eq!(
3289            plan["new_ids"],
3290            json!([{ "symbol": "$new.0", "kind": "block" }])
3291        );
3292        // 4.0: the retired inputs name their replacement (`spec/surface` §4);
3293        // `effects` is required; nothing is stripped silently.
3294        let dry = s.call("apply", json!({ "effects": effects, "dry_run": true }));
3295        assert_eq!(dry.body["error"], "filter_invalid", "{}", dry.body);
3296        assert_eq!(
3297            dry.body["message"],
3298            "`dry_run` is no longer an argument — preview with `plan { effects: [...] }` (apply takes effects, expect?, reason?, repo?, select?)"
3299        );
3300        let ops = s.call("apply", json!({ "ops": [] }));
3301        assert_eq!(ops.body["error"], "filter_invalid");
3302        assert_eq!(
3303            ops.body["message"],
3304            "`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?)"
3305        );
3306        assert_eq!(ops.body["data"]["hint"], "arguments");
3307        let neither = s.call("apply", json!({}));
3308        assert_eq!(neither.body["error"], "filter_invalid");
3309        assert_eq!(
3310            neither.body["message"],
3311            "`effects` is required (apply takes effects, expect?, reason?, repo?, select?)"
3312        );
3313        // The CLI's preview seam plans the same effects: the same plan, no
3314        // commit, nothing minted.
3315        let previewed = s
3316            .preview_call("apply", &json!({ "effects": effects }))
3317            .unwrap();
3318        assert_eq!(previewed["plan_id"], plan["plan_id"]);
3319        assert!(previewed.get("preview").is_some());
3320        let bad = s.call("apply", json!({ "effects": [{ "op": "nope" }] }));
3321        assert!(bad.is_error);
3322        assert_eq!(bad.body["error"], "filter_invalid");
3323        assert_eq!(bad.body["data"]["hint"], "arguments");
3324        assert!(
3325            bad.body["message"]
3326                .as_str()
3327                .unwrap()
3328                .starts_with("effects[0]:"),
3329            "{}",
3330            bad.body
3331        );
3332        // `execute` needs a plan or a digest + effects.
3333        let empty = s.call("execute", json!({}));
3334        assert!(empty.is_error);
3335        assert_eq!(empty.body["data"]["hint"], "arguments");
3336        // The plan, echoed back without its preview, commits.
3337        let mut echoed = plan.clone();
3338        echoed.as_object_mut().unwrap().remove("preview");
3339        let receipt = s.call("execute", json!({ "plan": echoed, "reason": "slice 1" }));
3340        assert!(!receipt.is_error, "{}", receipt.body);
3341        assert_eq!(receipt.body["plan_id"], plan["plan_id"]);
3342        assert_eq!(receipt.body["committed"], true);
3343        assert_eq!(receipt.body["commit"], "c_1");
3344        assert_eq!(receipt.body["revisions"][0]["_path"], "/a.md");
3345        assert_eq!(receipt.body["new_ids"], json!({ "$new.0": "b_2" }));
3346        let read = s.call("docs_read", json!({ "_path": "a.md" }));
3347        assert_eq!(
3348            read.body["_raw"],
3349            "---\nstatus: done\n---\n\n# A\n\nAlpha.\n\nTail.\n"
3350        );
3351        // A replay is stale; the digest form with the same effects re-plans.
3352        let again = s.call("execute", json!({ "plan": echoed }));
3353        assert!(again.is_error);
3354        assert_eq!(again.body["error"], "stale_plan");
3355        assert_eq!(again.body["retriable"], true);
3356        let digest = s.call(
3357            "execute",
3358            json!({ "plan_id": plan["plan_id"], "effects": effects }),
3359        );
3360        assert!(digest.is_error);
3361        assert_eq!(digest.body["error"], "plan_mismatch", "{}", digest.body);
3362        let half = s.call("execute", json!({ "plan_id": plan["plan_id"] }));
3363        assert!(half.is_error);
3364        assert!(
3365            half.body["message"]
3366                .as_str()
3367                .unwrap()
3368                .starts_with("the digest form needs both"),
3369            "{}",
3370            half.body
3371        );
3372        let v2 = s.call(
3373            "execute",
3374            json!({ "plan": { "version": 2, "plan_id": "x" } }),
3375        );
3376        assert_eq!(v2.body["error"], "unsupported_plan_version", "{}", v2.body);
3377        let ops_count: i64 = s
3378            .store()
3379            .conn()
3380            .query_row(
3381                "SELECT count(*) FROM commits WHERE ops IS NOT NULL",
3382                [],
3383                |r| r.get(0),
3384            )
3385            .unwrap();
3386        assert_eq!(ops_count, 1, "the effect commit recorded its intent");
3387        assert!(Surface::is_write_tool("plan") && Surface::is_write_tool("execute"));
3388    }
3389
3390    /// `bare: true` (§1.4, 4.2): hits are their projection only; identity
3391    /// stays internal, so paging is unchanged.
3392    #[test]
3393    fn bare_hits_are_the_projection_only() {
3394        let mut s = surface();
3395        for (path, content) in [
3396            ("a.md", "---\nlayer: canon\nid: authored-a\n---\n# Alpha\n"),
3397            ("b.md", "---\nlayer: canon\n---\n# Beta\n"),
3398            ("c.md", "---\nlayer: working\n---\n# Gamma\n"),
3399        ] {
3400            let out = s.call("observe", json!({ "path": path, "content": content }));
3401            assert!(!out.is_error, "{}", out.body);
3402        }
3403        // the default envelope is untouched
3404        let plain = s.call("query", json!({ "query": "select _title from docs" }));
3405        assert_eq!(
3406            plain.body["hits"][0],
3407            json!({ "_id": "d_0", "_path": "/a.md", "_title": "Alpha" })
3408        );
3409        // bare: the projection only, `truncated`/`cursor` as before
3410        let bare = s.call(
3411            "query",
3412            json!({ "query": "select _title from docs", "bare": true }),
3413        );
3414        assert!(!bare.is_error, "{}", bare.body);
3415        assert_eq!(
3416            bare.body["hits"],
3417            json!([{ "_title": "Alpha" }, { "_title": "Beta" }, { "_title": "Gamma" }])
3418        );
3419        assert_eq!(bare.body["truncated"], false);
3420        assert_eq!(bare.body["cursor"], Json::Null);
3421        assert_eq!(bare.body["consumer"], "collect");
3422        // `select _id`/`_path` under bare is the attribute, an ordinary
3423        // column in the query's own order; the authored `id:` key is the
3424        // bare `id` (5.0); with no envelope there is nothing to collide with
3425        let intrinsic = s.call(
3426            "query",
3427            json!({ "query": "select i: id, _id, _path from docs where _path == \"a.md\"", "bare": true }),
3428        );
3429        assert_eq!(
3430            intrinsic.body["hits"].to_string(),
3431            r#"[{"i":"authored-a","_id":"d_0","_path":"/a.md"}]"#
3432        );
3433        let no_collision = s.call(
3434            "query",
3435            json!({ "query": "select _path: layer from docs where _title == \"Alpha\"", "bare": true }),
3436        );
3437        assert_eq!(no_collision.body["hits"], json!([{ "_path": "canon" }]));
3438        assert_eq!(
3439            s.call("query", json!({ "query": "select _path: layer from docs" }))
3440                .body["error"],
3441            "filter_invalid"
3442        );
3443        // the scalar consumers have no hits and ignore it
3444        let count = s.call("query", json!({ "query": "docs count { }", "bare": true }));
3445        assert_eq!(count.body["count"], 3, "{}", count.body);
3446        // paging keeps the keyset cursor: identity is kept until the result is shaped
3447        let page1 = s.call(
3448            "query",
3449            json!({ "query": "select _title from docs", "bare": true, "limit": 2 }),
3450        );
3451        assert_eq!(page1.body["truncated"], true);
3452        assert_eq!(
3453            page1.body["hits"],
3454            json!([{ "_title": "Alpha" }, { "_title": "Beta" }])
3455        );
3456        let cursor = page1.body["cursor"].as_str().expect("a cursor").to_owned();
3457        let page2 = s.call(
3458            "query",
3459            json!({ "query": "select _title from docs", "bare": true, "limit": 2, "cursor": cursor }),
3460        );
3461        assert_eq!(page2.body["hits"], json!([{ "_title": "Gamma" }]));
3462        assert_eq!(page2.body["truncated"], false);
3463        // first/single and distinct are shaped the same way
3464        let first = s.call(
3465            "query",
3466            json!({ "query": "docs first { select layer where layer == \"working\" }", "bare": true }),
3467        );
3468        assert_eq!(first.body["hits"], json!([{ "layer": "working" }]));
3469        let distinct = s.call(
3470            "query",
3471            json!({ "query": "select distinct layer from docs", "bare": true }),
3472        );
3473        assert_eq!(
3474            distinct.body["hits"],
3475            json!([{ "layer": "canon" }, { "layer": "working" }])
3476        );
3477        // nested rows keep their `{ _id, _path }` reference shape
3478        let nested = s.call(
3479            "query",
3480            json!({ "query": "select b: _blocks collect { } from docs where _path == \"b.md\"", "bare": true }),
3481        );
3482        assert_eq!(
3483            nested.body["hits"],
3484            json!([{ "b": [{ "_id": "b_1", "_path": "/b.md" }] }])
3485        );
3486        // refused: no select, values, a non-boolean
3487        let no_select = s.call("query", json!({ "query": "from docs", "bare": true }));
3488        assert_eq!(no_select.body["error"], "filter_invalid");
3489        assert_eq!(
3490            no_select.body["message"],
3491            "`bare` needs a `select` — without a projection a hit is only its envelope"
3492        );
3493        let values = s.call(
3494            "query",
3495            json!({ "query": "select _title values from docs", "bare": true }),
3496        );
3497        assert_eq!(values.body["error"], "filter_invalid");
3498        assert_eq!(
3499            values.body["message"],
3500            crate::query::BARE_WITH_VALUES_MESSAGE
3501        );
3502        let typed = s.call(
3503            "query",
3504            json!({ "query": "select _title from docs", "bare": "yes" }),
3505        );
3506        assert_eq!(typed.body["error"], "filter_invalid");
3507        assert_eq!(
3508            typed.body["message"],
3509            "`bare` must be a boolean (query takes bare?, cursor?, limit?, query, repo?)"
3510        );
3511        // `bare: false` is the default envelope
3512        let off = s.call(
3513            "query",
3514            json!({ "query": "select _title from docs", "bare": false }),
3515        );
3516        assert_eq!(off.body["hits"][0]["_id"], "d_0");
3517        assert!(s.tools().iter().any(
3518            |t| t.name == "query" && t.input_schema["properties"]["bare"]["type"] == "boolean"
3519        ));
3520    }
3521
3522    #[test]
3523    fn observe_read_and_query_round_trip() {
3524        let mut s = surface();
3525        let out = s.call("observe", json!({ "path": "a.md", "content": "---\nlayer: canon\n---\n# Title\n\nHello world.\n\n- [ ] task one\n" }));
3526        assert!(!out.is_error, "{}", out.body);
3527        assert_eq!(out.body["_id"], "d_0");
3528        assert_eq!(out.body["echo"], false);
3529        let read = s.call("docs_read", json!({ "_path": "a.md", "include_ids": true }));
3530        assert!(!read.is_error, "{}", read.body);
3531        assert_eq!(read.body["_path"], "/a.md");
3532        // either form names the document (§1 Paths)
3533        assert_eq!(
3534            s.call("docs_read", json!({ "_path": "/a.md" })).body["_path"],
3535            "/a.md"
3536        );
3537        // §4 "Arguments": the retired `doc` is refused with its replacement;
3538        // (5.0) a bare `id`/`path` is refused with the `_` spelling and a fix.
3539        let retired = s.call("docs_read", json!({ "doc": "a.md" }));
3540        assert_eq!(retired.body["error"], "filter_invalid");
3541        assert_eq!(
3542            retired.body["message"],
3543            "`doc` is no longer an argument — pass `_id` or `_path` (docs_read takes _id?, _path?, at?, include_ids?, repo?)"
3544        );
3545        let neither = s.call("docs_read", json!({}));
3546        assert_eq!(
3547            neither.body["message"],
3548            "pass _id or _path (docs_read takes _id?, _path?, at?, include_ids?, repo?)"
3549        );
3550        let bare_path = s.call("docs_read", json!({ "path": "a.md" }));
3551        assert_eq!(
3552            bare_path.body["message"],
3553            "`path` is not an argument — the engine field is `_path` (the bare namespace is the user's); pass `_path` (docs_read takes _id?, _path?, at?, include_ids?, repo?)"
3554        );
3555        assert_eq!(bare_path.body["data"]["fix"], r#"{"_path":"a.md"}"#);
3556        assert_eq!(
3557            s.call("docs_read", json!({ "_path": "//a.md" })).body["_id"],
3558            "d_0"
3559        );
3560        assert_eq!(read.body["properties"]["frontmatter"]["layer"], "canon");
3561        assert!(read.body["ids"].as_array().unwrap().len() >= 3);
3562        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" }));
3563        assert!(!q.is_error, "{}", q.body);
3564        assert_eq!(q.body["hits"][0]["_title"], "Title");
3565        assert_eq!(q.body["hits"][0]["layer"], "canon");
3566        assert_eq!(q.body["hits"][0]["t"][0]["_value"], "task one");
3567        assert_eq!(q.body["hits"][0]["_id"], "d_0");
3568        assert_eq!(q.body["hits"][0]["_path"], "/a.md");
3569        assert_eq!(q.body["consumer"], "collect");
3570        let c = s.call(
3571            "query",
3572            json!({ "query": "blocks count { where text(\"hello\") }" }),
3573        );
3574        assert_eq!(c.body["count"], 1);
3575        // 5.0: `_path` is the attribute; a projection landing on the
3576        // envelope must be aliased.
3577        let ok = s.call(
3578            "query",
3579            json!({ "query": "from docs where _path == \"a.md\"" }),
3580        );
3581        assert!(!ok.is_error, "{}", ok.body);
3582        assert_eq!(ok.body["hits"][0]["_id"], "d_0");
3583        let bad = s.call("query", json!({ "query": "select _path: layer from docs" }));
3584        assert!(bad.is_error);
3585        assert_eq!(bad.body["error"], "filter_invalid");
3586        assert_eq!(
3587            bad.body["message"],
3588            "projection `_path` collides with the hit envelope — alias it (`p: …`)"
3589        );
3590        let sem = s.call(
3591            "query",
3592            json!({ "query": "from docs where semantic(\"x\") > 0.5" }),
3593        );
3594        assert_eq!(sem.body["error"], "provider_unavailable");
3595        assert_eq!(sem.body["data"], json!({ "provider": null }));
3596        let outline = s.call("docs_outline", json!({ "_path": "a.md" }));
3597        let text = outline.body["text"].as_str().unwrap();
3598        assert!(text.starts_with("b_0 h1   Title  §"), "{text}");
3599        assert!(text.contains("☐ task one"));
3600        let missing = s.call("docs_read", json!({ "_path": "nope.md" }));
3601        assert_eq!(missing.body["error"], "doc_missing");
3602        assert_eq!(
3603            missing.body["message"],
3604            "no document with _id or _path \"nope.md\""
3605        );
3606        assert_eq!(missing.body["data"], json!({ "_path": "nope.md" }));
3607        let repos = s.call("repos", json!({}));
3608        assert_eq!(repos.body["repos"][0]["slug"], "fixture");
3609        let unknown = s.call("docs_list", json!({ "repo": "zzz" }));
3610        assert_eq!(unknown.body["error"], "repo_not_found");
3611    }
3612
3613    #[test]
3614    fn insert_and_move_carry_the_destination_parent_cas_and_many_reads_keep_one_item() {
3615        let mut s = surface();
3616        s.call(
3617            "observe",
3618            json!({ "path": "a.md", "content": "# T\n\nOne.\n\nTwo.\n" }),
3619        );
3620        s.call("observe", json!({ "path": "b.md", "content": "# U\n" }));
3621        // §4 (1.2): a stale `expect.parent_children_hash` on blocks_insert is
3622        // the kernel's destination-parent CAS: `stale_expectation` carrying the
3623        // current hash, from which the caller retries without a read.
3624        let stale = s.call(
3625            "blocks_insert",
3626            json!({ "to": "a.md", "markdown": "Three.", "expect": { "parent_children_hash": "00" } }),
3627        );
3628        assert!(stale.is_error, "{}", stale.body);
3629        assert_eq!(stale.body["error"], "stale_expectation");
3630        assert_eq!(stale.body["retriable"], true);
3631        let current = stale.body["data"]["current"]["parent_children_hash"]
3632            .as_str()
3633            .expect("current hash")
3634            .to_owned();
3635        assert_eq!(current.len(), 64, "{current}");
3636        let ok = s.call(
3637            "blocks_insert",
3638            json!({ "to": "a.md", "markdown": "Three.", "expect": { "parent_children_hash": current, "content_hash": "dropped, not checked" } }),
3639        );
3640        assert!(!ok.is_error, "{}", ok.body);
3641        assert_eq!(ok.body["committed"], true);
3642        let read = s.call("docs_read", json!({ "_path": "a.md" }));
3643        assert_eq!(read.body["_raw"], "# T\n\nOne.\n\nTwo.\n\nThree.\n");
3644        // blocks_move: the destination is checked once for the whole run.
3645        let stale = s.call(
3646            "blocks_move",
3647            json!({ "blocks": ["b_1", "b_2"], "to": "a.md", "at": "end", "expect": { "parent_children_hash": "00" } }),
3648        );
3649        assert_eq!(stale.body["error"], "stale_expectation", "{}", stale.body);
3650        let current = stale.body["data"]["current"]["parent_children_hash"]
3651            .as_str()
3652            .expect("current hash")
3653            .to_owned();
3654        let ok = s.call(
3655            "blocks_move",
3656            json!({ "blocks": ["b_1", "b_2"], "to": "a.md", "at": "end", "expect": { "parent_children_hash": current } }),
3657        );
3658        assert!(!ok.is_error, "{}", ok.body);
3659        let read = s.call("docs_read", json!({ "_path": "a.md" }));
3660        assert_eq!(read.body["_raw"], "# T\n\nThree.\n\nOne.\n\nTwo.\n\n");
3661        // The preview (`plan`, or the CLI's seam) still runs the CAS.
3662        let dry = s.call(
3663            "plan",
3664            json!({ "effects": [{ "op": "move", "targets": [{ "kind": "block", "_id": "b_1" }], "into": { "kind": "document", "_path": "a.md" }, "at": "start", "expect": { "parent_children_hash": "00" } }] }),
3665        );
3666        assert_eq!(dry.body["error"], "stale_expectation", "{}", dry.body);
3667        let dry = s
3668            .preview_call(
3669                "blocks_move",
3670                &json!({ "blocks": ["b_1"], "to": "a.md", "at": "start", "expect": { "parent_children_hash": "00" } }),
3671            )
3672            .unwrap_err();
3673        assert_eq!(dry.code, "stale_expectation");
3674        // §2 (1.2): the budget is applied from the second item on — one item
3675        // always comes back, `truncated` says more remained.
3676        let many = s.call(
3677            "docs_read_many",
3678            json!({ "docs": ["a.md", "b.md"], "budget_tokens": 1 }),
3679        );
3680        assert_eq!(
3681            many.body["items"].as_array().unwrap().len(),
3682            1,
3683            "{}",
3684            many.body
3685        );
3686        assert_eq!(many.body["items"][0]["_path"], "/a.md");
3687        assert_eq!(many.body["truncated"], true);
3688        let one = s.call(
3689            "docs_read_many",
3690            json!({ "docs": ["b.md"], "budget_tokens": 1 }),
3691        );
3692        assert_eq!(one.body["items"].as_array().unwrap().len(), 1);
3693        assert_eq!(one.body["truncated"], false);
3694        let nodes = s.call(
3695            "blocks_read_many",
3696            json!({ "_ids": ["b_zzz", "b_1", "b_2"], "resolution": "full", "budget_tokens": 1 }),
3697        );
3698        assert_eq!(
3699            nodes.body["nodes"].as_array().unwrap().len(),
3700            1,
3701            "{}",
3702            nodes.body
3703        );
3704        assert_eq!(nodes.body["nodes"][0]["_id"], "b_1");
3705        assert_eq!(nodes.body["truncated"], true);
3706        assert_eq!(nodes.body["unresolved"], json!(["b_zzz"]));
3707    }
3708
3709    #[test]
3710    fn writes_go_through_the_fixed_doc_store_and_fire_the_hook() {
3711        use std::cell::Cell;
3712        use std::rc::Rc;
3713        let fired = Rc::new(Cell::new(0));
3714        let f2 = Rc::clone(&fired);
3715        let mut s = surface().with_mutation_hook(move || f2.set(f2.get() + 1));
3716        s.call(
3717            "observe",
3718            json!({ "path": "a.md", "content": "# T\n\nOne.\n" }),
3719        );
3720        assert_eq!(fired.get(), 1);
3721        let dry = s
3722            .preview_call(
3723                "blocks_insert",
3724                &json!({ "to": "a.md", "markdown": "Two." }),
3725            )
3726            .unwrap();
3727        assert!(dry.get("committed").is_none(), "a Plan: {dry}");
3728        assert_eq!(
3729            dry["new_ids"],
3730            json!([{ "symbol": "$new.0", "kind": "block" }])
3731        );
3732        assert_eq!(fired.get(), 1, "a preview never fires the hook");
3733        let plan = s.call("plan", json!({ "effects": [{ "op": "insert", "into": { "kind": "document", "_path": "a.md" }, "markdown": "Two." }] }));
3734        assert_eq!(
3735            plan.body["plan_id"], dry["plan_id"],
3736            "the preview IS the plan"
3737        );
3738        assert_eq!(fired.get(), 1, "`plan` never fires the hook");
3739        let wet = s.call("blocks_insert", json!({ "to": "a.md", "markdown": "Two." }));
3740        assert!(!wet.is_error, "{}", wet.body);
3741        assert_eq!(fired.get(), 2);
3742        // The Receipt (4.0), the preview's symbol realized.
3743        assert_eq!(wet.body["plan_id"], dry["plan_id"]);
3744        assert_eq!(wet.body["committed"], true);
3745        assert_eq!(wet.body["commit"], "c_1");
3746        assert_eq!(wet.body["new_ids"], json!({ "$new.0": "b_2" }));
3747        assert_eq!(wet.body["revisions"][0]["_path"], "/a.md");
3748        assert!(wet.body.get("results").is_none() && wet.body.get("diffs").is_none());
3749        let read = s.call("docs_read", json!({ "_id": "d_0" }));
3750        assert_eq!(read.body["_raw"], "# T\n\nOne.\n\nTwo.\n");
3751        let upd = s.call(
3752            "blocks_update",
3753            json!({ "_id": "b_1", "markdown": "One, edited." }),
3754        );
3755        assert!(!upd.is_error, "{}", upd.body);
3756        assert!(
3757            upd.body.get("_id").is_none() && upd.body.get("ids").is_none(),
3758            "{}",
3759            upd.body
3760        );
3761        assert_eq!(upd.body["revisions"].as_array().map(Vec::len), Some(1));
3762        let attrs = s.call(
3763            "blocks_update",
3764            json!({ "_id": "b_1", "attrs": { "checked": true } }),
3765        );
3766        assert_eq!(
3767            attrs.body["message"],
3768            "`attrs` is no longer an argument — pass `checked` (blocks_update takes _id, checked?, expect?, markdown?, repo?)"
3769        );
3770        let dry = s.call(
3771            "blocks_update",
3772            json!({ "_id": "b_1", "markdown": "x", "dry_run": true }),
3773        );
3774        assert_eq!(
3775            dry.body["message"],
3776            "`dry_run` is no longer an argument — preview with `plan { effects: [...] }` (blocks_update takes _id, checked?, expect?, markdown?, repo?)"
3777        );
3778        let nothing = s.call("blocks_update", json!({ "_id": "b_1" }));
3779        assert_eq!(
3780            nothing.body["message"],
3781            "pass `markdown` and/or `checked` (blocks_update takes _id, checked?, expect?, markdown?, repo?)"
3782        );
3783        // (5.0) the bare `id` is refused — never an alias — with the fix.
3784        let bare = s.call("blocks_update", json!({ "id": "b_1", "markdown": "x" }));
3785        assert_eq!(
3786            bare.body["message"],
3787            "`id` is not an argument — the engine field is `_id` (the bare namespace is the user's); pass `_id` (blocks_update takes _id, checked?, expect?, markdown?, repo?)"
3788        );
3789        assert_eq!(bare.body["data"]["fix"], r#"{"_id":"b_1","markdown":"x"}"#);
3790        let app = s.call(
3791            "docs_append",
3792            json!({ "_path": "a.md", "section": "T", "text": "Three." }),
3793        );
3794        assert!(!app.is_error, "{}", app.body);
3795        let amb = s.call(
3796            "docs_append",
3797            json!({ "_path": "a.md", "section": "Nope", "text": "x" }),
3798        );
3799        assert_eq!(amb.body["error"], "parent_missing");
3800        assert_eq!(amb.body["message"], "no heading \"Nope\" in /a.md");
3801        // blocks_append: a heading's section, a leaf is block_not_container.
3802        let into = s.call("blocks_append", json!({ "_id": "b_0", "text": "Four." }));
3803        assert!(!into.is_error, "{}", into.body);
3804        let leaf = s.call("blocks_append", json!({ "_id": "b_1", "text": "x" }));
3805        assert_eq!(leaf.body["error"], "block_not_container", "{}", leaf.body);
3806        // The fix (4.4): the sibling insert under the leaf's parent — the
3807        // document itself for a top-level leaf.
3808        assert_eq!(
3809            leaf.body["data"],
3810            json!({ "_id": "b_1", "_type": "paragraph", "fix": "blocks_insert {\"at\":{\"after\":\"b_1\"},\"markdown\":\"x\",\"to\":\"/a.md\"}" })
3811        );
3812        let hist = s.call("blocks_history", json!({ "_id": "b_1" }));
3813        let entries = hist.body.as_array().unwrap();
3814        assert!(entries.len() >= 2, "{}", hist.body);
3815        assert_eq!(entries[0]["origin"], "api", "newest first");
3816        let du = s.call("docs_diff_unified", json!({ "_path": "a.md" }));
3817        assert!(!du.is_error, "{}", du.body);
3818        assert!(du.body["diff"].as_str().unwrap().contains("+Four."));
3819    }
3820
3821    /// The document operations (§4, 4.0): each answers with the Receipt —
3822    /// `docs_move` adding `dangling`/`retargeted` — and previews through
3823    /// `plan` (the CLI's seam runs the tool's own checks): a create `"" →
3824    /// bytes`, a move two entries + the rewritten sources, a delete `bytes →
3825    /// ""`, a meta patch before → after; a preview commits nothing, fires no
3826    /// hook and burns no id.
3827    #[test]
3828    fn doc_tools_return_receipts_and_preview_through_plan() {
3829        use std::cell::Cell;
3830        use std::rc::Rc;
3831        let fired = Rc::new(Cell::new(0));
3832        let f2 = Rc::clone(&fired);
3833        let mut s = surface().with_mutation_hook(move || f2.set(f2.get() + 1));
3834        let b_src = "# B\n\nTarget.\n";
3835        let a_src = "# A\n\nSee [b](b.md).\n";
3836        let b = s.call("docs_create", json!({ "path": "b.md", "markdown": b_src }));
3837        assert!(!b.is_error, "{}", b.body);
3838        assert_eq!(
3839            b.body,
3840            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)"] }),
3841            "the created document is revisions[0], its symbols the document then its blocks"
3842        );
3843        let a = s.call("docs_create", json!({ "path": "a.md", "markdown": a_src }));
3844        assert_eq!(a.body["new_ids"]["$new.0"], "d_1", "{}", a.body);
3845        assert_eq!(fired.get(), 2);
3846        let count = |s: &Surface, table: &str| -> i64 {
3847            s.store
3848                .conn()
3849                .query_row(&format!("SELECT count(*) FROM {table}"), [], |r| r.get(0))
3850                .unwrap()
3851        };
3852        let snapshot = |s: &Surface| {
3853            (
3854                count(s, "docs"),
3855                count(s, "commits"),
3856                count(s, "revisions"),
3857                count(s, "blocks"),
3858            )
3859        };
3860        let before = snapshot(&s);
3861        let keys = |v: &Json| -> Vec<String> { v.as_object().unwrap().keys().cloned().collect() };
3862
3863        // docs_create preview: `""` → the composed file; the `d_` is a symbol.
3864        let c = s
3865            .preview_call(
3866                "docs_create",
3867                &json!({ "path": "c.md", "markdown": "# C", "frontmatter": { "title": "C" } }),
3868            )
3869            .unwrap();
3870        assert_eq!(
3871            c["preview"]["diffs"],
3872            json!({ "/c.md": { "before": "", "after": "---\ntitle: C\n---\n\n# C\n" } })
3873        );
3874        assert_eq!(c["preview"]["revisions"][0]["_id"], "$new.0");
3875        assert_eq!(
3876            c["new_ids"],
3877            json!([{ "symbol": "$new.0", "kind": "document" }, { "symbol": "$new.1", "kind": "block" }])
3878        );
3879
3880        // docs_move preview with retarget_inbound: the rewritten source, the
3881        // old path emptied, the new path filled — the planner's order.
3882        let m = s
3883            .preview_call(
3884                "docs_move",
3885                &json!({ "_path": "b.md", "to_path": "notes/b.md", "retarget_inbound": true }),
3886            )
3887            .unwrap();
3888        assert_eq!(
3889            keys(&m["preview"]["diffs"]),
3890            ["/a.md", "/b.md", "/notes/b.md"]
3891        );
3892        assert_eq!(
3893            m["preview"]["diffs"]["/a.md"],
3894            json!({ "before": a_src, "after": "# A\n\nSee [b](notes/b.md).\n" })
3895        );
3896        assert_eq!(
3897            m["preview"]["diffs"]["/b.md"],
3898            json!({ "before": b_src, "after": "" })
3899        );
3900        assert_eq!(
3901            m["preview"]["diffs"]["/notes/b.md"],
3902            json!({ "before": "", "after": b_src })
3903        );
3904        assert!(
3905            m.get("dangling").is_none(),
3906            "a preview is the Plan alone: {m}"
3907        );
3908        // … and with `retarget_inbound: false`: two entries.
3909        let plain = s
3910            .preview_call(
3911                "docs_move",
3912                &json!({ "_path": "b.md", "to_path": "notes/b.md", "retarget_inbound": false }),
3913            )
3914            .unwrap();
3915        assert_eq!(keys(&plain["preview"]["diffs"]), ["/b.md", "/notes/b.md"]);
3916
3917        // docs_delete preview: the file → `""`.
3918        let d = s
3919            .preview_call("docs_delete", &json!({ "_path": "a.md" }))
3920            .unwrap();
3921        assert_eq!(
3922            d["preview"]["diffs"],
3923            json!({ "/a.md": { "before": a_src, "after": "" } })
3924        );
3925
3926        // docs_set_meta preview: before → after.
3927        let sm = s
3928            .preview_call(
3929                "docs_set_meta",
3930                &json!({ "_path": "a.md", "set": { "status": "open" } }),
3931            )
3932            .unwrap();
3933        assert_eq!(
3934            sm["preview"]["diffs"],
3935            json!({ "/a.md": { "before": a_src, "after": "---\nstatus: open\n---\n\n# A\n\nSee [b](b.md).\n" } })
3936        );
3937
3938        // Nothing committed, no hook, no row changed, no id burned; the
3939        // checks still run; `dry_run` is a retired name.
3940        assert_eq!(fired.get(), 2, "previews never fire the mutation hook");
3941        assert_eq!(snapshot(&s), before);
3942        let taken = s.preview_call("docs_create", &json!({ "path": "a.md", "markdown": "x" }));
3943        assert_eq!(taken.unwrap_err().code, "path_taken");
3944        let missing = s.preview_call("docs_delete", &json!({ "_path": "nope.md" }));
3945        assert_eq!(missing.unwrap_err().code, "doc_missing");
3946        let dry = s.call("docs_delete", json!({ "_path": "a.md", "dry_run": true }));
3947        assert_eq!(
3948            dry.body["message"],
3949            "`dry_run` is no longer an argument — preview with `plan { effects: [...] }` (docs_delete takes _id?, _path?, repo?)"
3950        );
3951        let real = s.call(
3952            "docs_create",
3953            json!({ "path": "c.md", "markdown": "# C\n" }),
3954        );
3955        assert_eq!(
3956            real.body["new_ids"]["$new.0"], "d_2",
3957            "the preview burned no `d`"
3958        );
3959        assert_eq!(fired.get(), 3);
3960
3961        // The committed move: the Receipt (the rewritten source's revision),
3962        // then `dangling` and `retargeted`.
3963        let moved = s.call(
3964            "docs_move",
3965            json!({ "_path": "b.md", "to_path": "notes/b.md" }),
3966        );
3967        assert!(!moved.is_error, "{}", moved.body);
3968        assert_eq!(
3969            keys(&moved.body),
3970            [
3971                "plan_id",
3972                "commit",
3973                "revisions",
3974                "new_ids",
3975                "committed",
3976                "summary",
3977                "retargeted",
3978                "dangling"
3979            ]
3980        );
3981        assert_eq!(
3982            moved.body["revisions"],
3983            json!([{ "_id": "d_1", "_path": "/a.md", "_hash": moved.body["revisions"][0]["_hash"] }])
3984        );
3985        assert_eq!(moved.body["dangling"], json!([]));
3986        assert_eq!(
3987            moved.body["retargeted"],
3988            json!({ "blocks": ["b_3"], "docs": ["d_1"] })
3989        );
3990        assert_eq!(
3991            s.call("docs_read", json!({ "_id": "d_0" })).body["_path"],
3992            "/notes/b.md"
3993        );
3994        // Opted out: the link dangles, as a link row with its source rooted.
3995        let back = s.call(
3996            "docs_move",
3997            json!({ "_path": "notes/b.md", "to_path": "b.md", "retarget_inbound": false }),
3998        );
3999        assert_eq!(back.body["revisions"], json!([]), "{}", back.body);
4000        assert_eq!(
4001            back.body["dangling"],
4002            json!([{ "_doc_id": "d_1", "_path": "/a.md", "_block_id": "b_3", "target": "notes/b.md", "_anchor": null }])
4003        );
4004        assert_eq!(back.body["retargeted"], Json::Null);
4005        // A set_meta and a delete: Receipts; the delete has no revision.
4006        let sm = s.call(
4007            "docs_set_meta",
4008            json!({ "_path": "a.md", "set": { "status": "open" } }),
4009        );
4010        assert_eq!(
4011            sm.body["revisions"].as_array().map(Vec::len),
4012            Some(1),
4013            "{}",
4014            sm.body
4015        );
4016        assert_eq!(sm.body["committed"], true);
4017        let d = s.call("docs_delete", json!({ "_path": "a.md" }));
4018        assert_eq!(d.body["revisions"], json!([]), "{}", d.body);
4019        assert!(d.body["commit"].is_string());
4020        assert_eq!(
4021            s.call("docs_read", json!({ "_path": "a.md" })).body["error"],
4022            "doc_missing"
4023        );
4024    }
4025
4026    #[test]
4027    fn change_summaries_are_rooted_like_the_reference_renders_them() {
4028        assert_eq!(
4029            root_summary("observed: a.md, b/c.md — 3 inserted, 1 edited"),
4030            "observed: /a.md, /b/c.md — 3 inserted, 1 edited"
4031        );
4032        assert_eq!(
4033            root_summary("observed: deleted a.md"),
4034            "observed: deleted /a.md"
4035        );
4036        assert_eq!(
4037            root_summary("api(human:spec): moved a.md → b/a.md"),
4038            "api(human:spec): moved /a.md → /b/a.md"
4039        );
4040        assert_eq!(
4041            root_summary("api(human:spec): moved a.md → b/a.md; index.md, c.md — 31 edited"),
4042            "api(human:spec): moved /a.md → /b/a.md; /index.md, /c.md — 31 edited"
4043        );
4044        assert_eq!(root_summary("api(?): x.md"), "api(?): /x.md");
4045        assert_eq!(root_summary("observed: "), "observed: ");
4046        assert_eq!(root_summary("nonsense"), "nonsense");
4047        let page = root_changes_page(json!({
4048            "digests": [{ "summary": "observed: a.md", "revisions": [{ "_id": "d_0", "_path": "a.md", "_hash": "ff" }] }],
4049            "cursor": 1, "truncated": false, "head": 1
4050        }));
4051        assert_eq!(page["digests"][0]["revisions"][0]["_path"], "/a.md");
4052        assert_eq!(page["digests"][0]["summary"], "observed: /a.md");
4053    }
4054
4055    /// `spec/surface` §4 (4.5): `plan` returns `preview.diffs` only on
4056    /// `diffs: true`; the preview is otherwise identical and `plan_id` the
4057    /// same either way (the digest is over the core).
4058    #[test]
4059    fn plan_returns_diffs_only_on_request() {
4060        let mut s = surface();
4061        let out = s.call(
4062            "observe",
4063            json!({ "path": "a.md", "content": "# A\n\nOne.\n" }),
4064        );
4065        assert!(!out.is_error, "{}", out.body);
4066        let effects = json!([{ "op": "insert", "into": "/a.md", "markdown": "Two." }]);
4067        let bare = s.call("plan", json!({ "effects": effects }));
4068        assert!(!bare.is_error, "{}", bare.body);
4069        assert!(bare.body["preview"].get("diffs").is_none(), "{}", bare.body);
4070        assert!(bare.body["preview"].get("summary").is_some());
4071        let with = s.call("plan", json!({ "effects": effects, "diffs": true }));
4072        assert_eq!(
4073            with.body["preview"]["diffs"]["/a.md"]["after"],
4074            "# A\n\nOne.\n\nTwo.\n"
4075        );
4076        assert_eq!(with.body["plan_id"], bare.body["plan_id"]);
4077        let mut p = with.body["preview"].as_object().unwrap().clone();
4078        p.remove("diffs");
4079        assert_eq!(Json::Object(p), bare.body["preview"]);
4080        let off = s.call("plan", json!({ "effects": effects, "diffs": false }));
4081        assert!(off.body["preview"].get("diffs").is_none());
4082    }
4083
4084    /// `spec/surface` §4 "Arguments" (4.5): a bare string in a string-list
4085    /// slot reaches the handler as a one-element list; a comma-joined one is
4086    /// refused with the split call as its `fix`.
4087    #[test]
4088    fn string_list_arguments_take_one_bare_string() {
4089        let mut s = surface();
4090        for (path, content) in [("a.md", "# A\n\nOne.\n"), ("b.md", "# B\n")] {
4091            let out = s.call("observe", json!({ "path": path, "content": content }));
4092            assert!(!out.is_error, "{}", out.body);
4093        }
4094        let one = s.call("docs_read_many", json!({ "docs": "a.md" }));
4095        assert!(!one.is_error, "{}", one.body);
4096        assert_eq!(one.body["items"][0]["_path"], "/a.md");
4097        let ids = s.call("blocks_read_many", json!({ "_ids": "b_1" }));
4098        assert_eq!(ids.body["nodes"][0]["_id"], "b_1");
4099        let roots = s.call("graph", json!({ "roots": "a.md", "degrees": 0 }));
4100        assert_eq!(roots.body["roots"], json!(["d_0"]));
4101        let joined = s.call(
4102            "docs_read_many",
4103            json!({ "docs": "a.md, b.md", "include_ids": true }),
4104        );
4105        assert!(joined.is_error);
4106        assert_eq!(joined.body["error"], "filter_invalid");
4107        assert_eq!(
4108            joined.body["data"]["fix"],
4109            r#"{"docs":["a.md","b.md"],"include_ids":true}"#
4110        );
4111    }
4112
4113    /// `spec/mutate` §8 (3.4): the shape fixes reach the catalog's plain
4114    /// `effects` path — the anchor fix reads the block's live row.
4115    #[test]
4116    fn plain_shape_errors_carry_their_fix() {
4117        let mut s = surface();
4118        let out = s.call(
4119            "observe",
4120            json!({ "path": "a.md", "content": "# A\n\n- one\n  - nested\n" }),
4121        );
4122        assert!(!out.is_error, "{}", out.body);
4123        for tool in ["plan", "apply"] {
4124            let e = s.call(tool, json!({ "effects": [{ "op": "insert", "into": { "kind": "block", "_id": "b_3" }, "at": "after", "markdown": "x" }] }));
4125            assert!(e.is_error, "{tool}: {}", e.body);
4126            assert_eq!(e.body["error"], "filter_invalid");
4127            assert_eq!(
4128                e.body["data"]["fix"],
4129                r#"{"effects":[{"at":{"after":"b_3"},"into":{"_id":"b_2","kind":"block"},"markdown":"x","op":"insert"}]}"#,
4130                "{tool}"
4131            );
4132        }
4133        let e = s.call("plan", json!({ "effects": [{ "op": "set", "target": { "kind": "block", "_id": "b_1" }, "field": "text", "value": "x" }] }));
4134        assert_eq!(
4135            e.body["data"]["fix"],
4136            r#"{"effects":[{"field":"markdown","op":"set","target":{"_id":"b_1","kind":"block"},"value":"x"}]}"#
4137        );
4138    }
4139}