Skip to main content

omgbase_surface/
catalog.rs

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