Skip to main content

omgbase_surface/
catalog.rs

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