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