Date: 2026-05-17. Status: Proposal. Open questions at the bottom. Scope: The contract between an agent (or agent harness) and an Escurel tenant. Defines the tool surface exposed over MCP and CLI, the meta-skill that teaches the agent how to use the surface, and the behavioural promises both sides depend on.
This document is a design proposal, not a spec. It draws on what
worked in the real-LLM run (real-llm-run/results.md) and on the
three-axis reframing in the paper.
The design follows from the paper:
-
One referent space. The agent has exactly one mental model: "find the typed page or block I need." No second model for external data, no second model for time. Same primitives across the kind / time / origin axes.
-
Stateless per call. Each tool call returns a complete result. No cursors, no session affinity, no pinned context. The agent keeps its own working set; the KB just answers questions.
-
Tier-1 cheap, Tier-2 lazy. Discovery primitives return metadata first (skill id + description + frontmatter excerpt). Body retrieval requires a deliberate
expand(). Matches the simulated-and-now-real-LLM cost result: 1 to 2 tool calls per cold start. -
Read OR write, never both in one call. All read primitives are safe to call speculatively. All write primitives go through validation. No tool both retrieves and mutates.
-
Validation is a first-class output. Both
validate()(dry run) andupdate_page()(live) return the same issue list. The authoring feedback channel is the engine path — the same one verified in H-AUTHORING-1. -
The same surface exposes markdown and external data. A
[[table::*]]instance whose body declarescatalog: ducklakeis reached via the sameresolve()/expand()calls as a[[person::*]]. The dispatcher hides the lane; the agent does not need to know which store backs which instance.
Fourteen tools, grouped by axis. The four write tools split between "live" (CRDT op stream) and "fallback" (whole-page submission) — see §"Write path in detail" below. The two append-mostly chat tools (M-Chat, ADR-0002) sit alongside the typed KB; conversation history is not embedded into the skill/instance model.
| tool | inputs | output | axis | notes |
|---|---|---|---|---|
search |
q: str, k: int = 10, granularity: 'block'|'page' = 'block', page_type?: 'skill'|'instance'|'any' = 'any', skill?: str |
list of hits (shape depends on granularity — see §"search granularity in detail") |
kind | natural-language vector + FTS hybrid; optional skill= filter pushes a link_skill predicate to DuckDB |
resolve |
wikilink: str |
{page_id, skill, page_type, description, exists: bool, error?} |
kind | parses [[skill::id#anchor@version|alias]]; reports validation errors without raising |
expand |
page_id: str, anchor?: str, version?: str |
{frontmatter, body, blocks: [{anchor, content}], wikilinks_out: [...]} |
kind / time | the body fetch; version is honoured only for instances whose backing store is versioned |
neighbours |
page_id: str, direction: 'in'|'out'|'both' = 'both', link_skill?: str |
list of {src, dst, link_skill, link_version?, anchor?} |
kind / time | the link-graph primitive; covers both backlinks and forward-links; time-axis traversal (prev_review, supersedes) uses the appropriate link_skill |
list_skills |
— | list of {id, description, required_frontmatter, optional_frontmatter} |
kind | the Tier 1 catalogue; semantically a search(*, page_type='skill') shortcut |
list_instances |
skill_id: str, filter?: {frontmatter clauses}, order_by?: str, limit?: int |
list of {id, frontmatter} |
kind / time | search-shaped shortcut over neighbours(skill, link_skill=skill); supports e.g. {status: open} to enumerate open decisions, {prev_review: null} to find the head of an append-only chain, or {at: '>= 2026-04-01'} plus order_by='at desc' to scan an event-typed skill's recent log |
query_instance |
ref: str (query_id accepted as an alias), params?: {…} |
{rows: [...], schema: [...], truncated} |
origin | resolves [[query::<ref>]] and executes it; parameters bound as typed values per the query's params: schema (see §"Query parameters in detail"). The one query surface — the legacy admin-gated run_stored_query was removed (2026-08-14 surface consolidation) |
validate |
content: str, as_page_id?: str |
{issues: [...]} |
write | dry run; same issue list as update_page/apply_op but no commit. Used for authoring feedback. |
open_session |
page_id: str |
{session, head_version, content} |
write (live) | open a CRDT session; agent enters the live-edit lane (see §"Write path in detail") |
apply_op |
session: str, op: CRDTOp |
{ok, conflicts?: [...]} |
write (live) | apply a single CRDT op; concurrent merges are handled by Loro |
close_session |
session: str, commit: bool = true |
{final_version, issues} |
write (live) | materialise the CRDT state to canonical markdown |
update_page |
page_id: str, content: str, base_version?: str |
{ok, issues, new_version} |
write (fallback) | whole-page write for environments without CRDT op stream support (no CSP fix); server diffs against current CRDT state and applies as ops |
append_message |
chat_group_id: str, role: str, content: str, author?: str, ts?: str, metadata?: object, msg_id?: str, embed: bool = true |
{msg_id, ts} |
chat (append) | append-mostly conversation log; chat_group_id is opaque to escurel; embedding is opt-out per call. Distinct from update_page — does not rewrite a page. See ADR-0002. |
list_messages |
chat_group_id: str, since?: str, until?: str, limit: int = 100, cursor?: str, direction: 'asc'|'desc' = 'desc' |
{messages: [...], next_cursor?} |
chat (read) | time-ordered read; since inclusive, until exclusive; cursor over (ts, msg_id). No embedding lookup on the read path. |
Four tools, called by operators or by the build pipeline.
| tool | purpose |
|---|---|
audit |
drift detection: returns two sets — markdown-not-in-duckdb, indexed-but-no-markdown |
rebuild |
recovery: drops the affected stores for scope (whole tenant or page subset) and re-runs the indexer from canonical markdown. Cost ~32 ms/page (T2 [6]) |
attach_external |
wire a DuckLake (or other) catalog read-only into the per-tenant external.ducklake ATTACH point. Required before [[table::ext.*]] resolution works |
export / import |
per-tenant directory tar+restore (F2 / T5); the unit of backup IS the tenant dir |
The CLI also exposes every agent-facing tool, so an operator can
script the same primitives the agent uses (this is what the
real-LLM run did via agent_tools.py).
Four tools, used by operator UIs (the
escurel-explore editor's Dev
Inspector drawer in particular) and by tenant migration tooling.
Distinct from the four CLI ops tools above because:
- They are MCP-served, not CLI-only. An operator can drive them from a browser-side admin client without an ssh hop.
- They cover inspection of lane/index/page state, not
destructive operations. Mutation still goes through the
agent-facing
update_page(with the same validation pipeline) or the CLIrebuild(for recovery). - They require the
escurel-adminrole (an OIDC JWT claim). Without it the dispatcher returnspermission_deniedwithout revealing whether the tool exists.
| tool | inputs | output | purpose |
|---|---|---|---|
admin_list_lanes |
— | list of {name, backend, tenants_present} |
enumerate the LaneStores the server has registered. Tells you whether Fs and S3 are both wired up and which tenants have content in each |
admin_lane_keys |
lane: str, prefix?: str, limit: int = 100 |
list of {key, size_bytes, last_modified?} |
list keys under prefix in lane. What's actually on disk / in S3 for this tenant, ordered by key |
admin_lane_blob |
lane: str, key: str |
binary {bytes, content_type} |
fetch one blob raw — the Inspector renders bytes hex+text for debug. Subject to a server-side size limit (default 1 MiB) |
admin_index_query |
table: str, filter?: {…}, limit?: int |
{columns, rows, snapshot_version?} |
paginated read of one of the index tables (pages, links, blocks, crdt_ops, crdt_snapshots, frontmatter_index, chat_messages). Read-only by design |
delete_chat_history (MCP: admin_delete_chat_history) |
tenant_id: str, chat_group_id?: str, before_ts?: str, author?: str |
{deleted} |
destructive purge for the M-Chat conversation log. All three filters optional and composed with AND; combinations cover GDPR right-to-erasure of a whole group (chat_group_id set) or a single member across groups (author set — issue #63), retention pruning (before_ts set), per-group pruning (group + cutoff), and full-tenant wipe (all unset). The agent surface has no chat-delete tool by design — erasure is operator-controlled. See ADR-0002 |
Scope: today admin tools operate against the single tenant the
server instance is scoped to, identical to the agent surface.
Once federation lands (F1 / G1) admin tools gain an explicit
tenant: str parameter; cross-tenant calls remain
escurel-admin-gated plus a per-tenant admin_grant claim.
admin_index_query is the one tool that intentionally exposes raw
relational reads — it bypasses the query_instance sandboxing
because the operator inspecting the index needs to issue ad-hoc
projections. The escurel-admin role is the gate; without it this
tool is invisible.
A second, escurel-admin-gated group manages instance backends (see
Origin axis › Backend-sourced instances and
../spec/protocol.md):
register_credential / list_credentials / delete_credential (the
server-side sql_view secret registry; secrets are never echoed back),
create_sql_instance (materialise a read-only view-backed instance), and
validate_bindings (re-probe every sql_view for schema drift). Document
uploads use the authenticated POST /ingest / POST /ingest/upload HTTP
routes rather than an MCP tool.
- No direct SQL. Agents reach the relational store only through
query_instance(which dispatches to a[[query::*]]instance authored ahead of time, with declared schema). This is the H-SECURITY-1 pattern: SQL is sandboxed AND author-mediated. - No raw vector access. Agents call
search, notembedand notvector_searchdirectly. The embedding model is an implementation detail. - No cross-tenant operations. Each MCP server instance is scoped to one tenant; cross-tenant federation is a separate layer (F1 / G1).
Every tenant ships with one mandatory skill page whose id is
literally escurel. The agent loads it once per session via
the same list_skills path as any other skill. The skill body
teaches the agent the disclosure model and the tool surface.
Proposed contents:
---
type: skill
id: escurel
description: How this knowledge base is organised and how to navigate
it. Read this first when entering a new tenant.
required_frontmatter: []
optional_frontmatter: []
---
# escurel — how this knowledge base is organised
This tenant follows the skill::instance pattern. Every page is
either a SKILL (a type declaration) or an INSTANCE (a memory of
that type). Skill pages declare what their instances look like
via `required_frontmatter`. Instance pages declare which skill
they conform to via `skill: <skill-id>`.
## Discovery (start here)
Two valid policies — pick whichever fits the task:
- **Catalogue-first.** Call `list_skills`. The result is the full
Tier 1 catalogue of `(id, description)` for every skill in this
tenant. Match your question against the descriptions; descend
to instances via `list_instances(<skill>)`. ~1 call per
cold-start question.
- **Search-first.** Call `search(<question>)`. The top hit
returns its `skill:` field; if the hit is an instance, you have
identified the skill via 1 call (plus optionally `expand` to
read the body). If the hit is a skill page, you're done in 1
call. ~2 calls per cold-start question.
Either way, do NOT preload page bodies for discovery. Tier 1
description-only retrieval clears 83% top-1 on a 169-page
corpus (HYP-E1); body retrieval is a deliberate `expand` once
relevance is established.
## Navigation (after you've found a starting point)
The graph is fully connected through typed wikilinks. From any
page:
- `neighbours(page_id)` — the typed/bare links pointing TO and
FROM that page, with `link_skill` carried in each row.
- `neighbours(page_id, link_skill=<S>)` — only links cited
through skill S. "Who cites Acme through a decision-record?"
is one call.
- `expand(page_id)` — the full body, including all outbound
wikilinks. Use sparingly; descriptions usually suffice.
## Time axis
The time axis has two sub-axes: what happened (the event log)
and what is true (state at time T). Four patterns are
recognised by convention; none needs a special tool.
- **Event log.** Skills whose `required_frontmatter` includes
`at:` are **event-typed**. Common members: `meeting`,
`email`, `call`, `message`, `incident`, `commit`, `release`.
Each event instance cites the entities it affects through
ordinary typed wikilinks (e.g. `with: [[customer::acme-corp]]`,
`participants: [...]`, `about: [[project::phoenix]]`). To
reach an entity's timeline, call
`neighbours(<entity>, link_skill IN (<event-skills>))` and
sort by `at`. Use `list_instances(<event-skill>,
filter={at: '>= 2026-04-01'})` to filter by date. Events are
*immutable* by convention; corrections are new event
instances with a `corrects: [[event::prev-id]]` link rather
than edits to the original.
- **Append-only chains.** A skill whose
`optional_frontmatter` includes `prev_<X>` (e.g.
`prev_review`) carries a chain. The head is the instance with
no inbound `prev_X` link. Walk backwards via
`neighbours(head, link_skill=<skill>)`. A skill may be event-typed
AND chain-typed at the same time (e.g. `weekly-review` has
both `at:` and `prev_review:`); the two patterns coexist.
- **Supersession.** A skill whose `optional_frontmatter` includes
`supersedes` carries mutable state. "Current state" =
instances with no outbound `supersedes` chain pointing TO them.
Events often *cause* a supersession (the QBR meeting's
`follow_ups: [[decision-record::...]]` is the typical
pattern); the system records both — the event and the state
change — without deriving one from the other.
- **Snapshot pinning.** For external versioned tables (`table`
instances with `versioned: true`), pin a snapshot at the link
site: `[[table::customers.churn@v14]]`. The `@version` segment
is honoured by `resolve` and `expand` only for versioned
instances; markdown instances ignore it silently.
## Origin axis
External structured data lives as instances of two existing
skills:
- `[[table::<id>]]` — the body describes the table; the
frontmatter declares `catalog`, `schema`, `name`, and
`versioned`. Use `expand` to read the schema documentation;
use `query_instance` (via a `[[query::*]]` page) to read the data.
- `[[query::<id>]]` — the body declares an SQL view. Run via
`query_instance(<id>)` (`query_id` accepted as an alias for
`ref`). The query's frontmatter declares
`db` (`relational` for internal, `ext` for the attached
DuckLake catalog) and an optional `params` schema.
There is no separate tool for external data. You traverse it
with the same primitives.
### Backend-sourced instances
A skill may also declare an **instance backend** in its frontmatter
(`backend: { kind: … }`), so its *instances* are sourced from outside
markdown. `list_skills` reports each skill's `backend.kind` (`markdown` |
`sql_view` | `document`) and a `capabilities` object. Whatever the backend,
the instance is still a page in the referent space — you `resolve` / `expand`
/ `neighbours` / `search` it with the same primitives — but two kinds are
**read-only** (`capabilities.writable == false`):
- **`sql_view`** — the instance projects a read-only DuckDB view over an
external source. `expand` returns the markdown overlay plus a bounded row
projection (`backend_projection`); a colliding source field appears under
`source.<field>`. An operator creates one with `create_sql_instance`; an
admin registers the source secret with `register_credential` (never in
markdown) and re-checks drift with `validate_bindings` (a degraded binding
reads fail-closed). Editing it (`update_page` / `apply_op`) returns
`backend_read_only`.
- **`document`** — the instance is an uploaded file (PDF / DOCX / PPTX / XLSX /
text) extracted, chunked, and embedded. An external client uploads via
`POST /ingest` / `POST /ingest/upload`; escurel records an ingest event and
materialises the instance asynchronously. `expand` returns the overlay plus
the top-k relevant chunks (`chunks_total` / `chunks_truncated`), never the
full text. Read-only; the retained blob is canonical.
Full wire detail in [`../spec/protocol.md`](../spec/protocol.md#instance-backends).
Orthogonally to its backend, every page carries a stability **layer**
(REQ-LAYER-01): `overlay` — tenant-authored, editable, the default for
every page with no `layer:` frontmatter — or `base@<pack>@<version>` —
imported from a subscribed skill pack, **read-only at this node**.
Writing a base-layer page (`update_page`, `open_session`/`apply_op`)
returns `layer_read_only`; so does a draft that *declares* `layer:
base@…` (base pages are created by pack import only). `list_skills`
reports each skill's `layer` so an agent can tell the stable,
firm-authored substrate from the tenant's own editable pages. To
specialise a base skill, author an overlay page with the same skill id
— never edit the base: the overlay then **shadows** the base
(`resolve` prefers it; `list_skills` shows one entry carrying a
`shadows: base@<pack>@<version>` pin; `expand` exposes the base
frontmatter under a `shadow` object so drift stays visible).
## Writing
Two modes, both backed by the same CRDT layer (Loro) under
the hood:
- **Live mode.** `open_session(page_id)` returns a session
token + the current head state. Issue `apply_op(session,
<op>)` for each granular edit; the server merges concurrent
edits from other actors and returns the merged content on
each response. `close_session(session, commit=true)`
materialises the state to canonical markdown and triggers
the indexer. Use this when you're co-editing with a user or
another agent.
- **Fallback (whole-page) mode.** `update_page(page_id,
content, base_version=<v>)` submits the full markdown body.
The server diffs against current CRDT state and applies as
ops. If concurrent edits make a clean merge impossible, the
call returns `{ok: false, issues: [{code: 'conflict',
...}]}` with the new content for you to re-draft. Use this
when the MCP transport does not support op streaming, or
when you're authoring a whole new page from scratch.
Before either, call `validate(<content>)` to see what the
indexer would say without committing. All three (`validate`,
`apply_op`, `update_page`) return the same issue shape:
{severity: 'error'|'warning', code: , location: , message: , suggestion?: }
An `error`-severity issue rejects the write;
`warning`-severity issues commit but appear in the response.
The codes are documented in `[[error-catalogue]]` (one bare
wikilink — the catalogue is itself a `note` instance).
## Tool surface summary
| tool | what for |
|---|---|
| `search` | natural-language → top-K hits (block or page granularity via `granularity=`) |
| `resolve` | `[[wikilink]]` → `(page_id, skill, exists)` |
| `expand` | page id → body + blocks + wikilinks_out |
| `neighbours` | one-hop graph traversal, typed-filterable |
| `provenance_ancestry` / `provenance_report` | bounded multi-hop provenance-graph reads ([ADR-0010](../adr/0010-project-memory-provenance-graph.md)); the `project-memory` vertical. `provenance_ancestry` with `to_page` is the path/reachability form (formerly `provenance_path`); `provenance_report(kind: "drift"\|"abandoned")` returns `{kind, rows}` (formerly `expectation_drift` / `abandoned_paths`) |
| `list_skills` | the Tier 1 catalogue |
| `list_instances` | enumerate instances of a skill, optional frontmatter filter, optional ordering (`order_by`) — typical event-log call is `list_instances('meeting', filter={at: '>= 2026-04-01'}, order_by='at desc')` |
| `query_instance` | execute a `[[query::*]]` instance with typed parameters |
| `validate` | dry-run the indexer's checks on a draft |
| `open_session` / `apply_op` / `close_session` | live CRDT editing (when the MCP client supports the op stream) |
| `update_page` | whole-page write fallback (no CRDT op stream required) |
## Anti-patterns
- Do NOT call `expand` on every search hit; it's the most
expensive primitive. Descriptions in the search result are
usually enough.
- Do NOT enumerate the whole catalogue if the task is narrow;
search-first reaches the right skill in 2 calls.
- Do NOT write raw SQL through `query_instance` — the dispatcher
refuses queries that aren't `[[query::*]]` instances. Author
the query as a page first, then call it.
- Do NOT trust a page body's mention of an entity over a typed
wikilink. `mentions: [Acme]` in frontmatter is an unvalidated
freeform string; `[[customer::acme-corp]]` is a validated
citation.
The escurel skill is the only mandatory skill in a tenant. Every
other skill is optional and tenant-specific.
A typical agent session against this surface:
-
Boot. Agent connects to the MCP server. No discovery call yet — the agent has a task in hand.
-
First call. Either
list_skills(catalogue-first) orsearch(<task>)(search-first). Either yields enough to identify the relevant skill. -
Identify the relevant instance(s). Either
list_instances(<skill>)(if the task names the type), or the search hits already point at specific instances. -
Selective expansion.
expandthe candidates that the description suggests are relevant. Most tasks need 1–3expandcalls. -
Traverse the graph if needed.
neighbours(page, link_skill=<...>)to follow typed links — get all decisions citing a customer; get all weekly-reviews citing a person; walk theprev_reviewchain back six weeks. -
Execute stored queries if needed.
query_instancefor anything that needs the relational or external lane (e.g. "the trend in Acme's churn score over Q1"). -
Write back if the task is editorial.
validatethe draft; if clean,update_page. If issues, fix them based on the issue codes.
The real-LLM run measured the read path: median 1–2 tool calls for discovery, 2–7 calls for closed-loop tasks like "list open decisions citing Phoenix". The write path is not yet measured end-to-end; the H-AUTHORING-1 engine-path verification confirms the issue codes are produced correctly.
Comparing to the prototype's current Tools class
(2026-05-16-kb-e2e-skills-prototype/src/kb.py plus the
real-LLM driver agent_tools.py):
| prototype today | this design | reason |
|---|---|---|
vector_search(q, k) |
search(q, k, page_type?, skill?) |
needs page_type filter for the search-first cold-start path; needs skill filter for typed retrieval |
no resolve |
resolve(wikilink) |
the agent needs to programmatically check whether a link is valid before traversing |
read_page |
expand(page_id, anchor?, version?) |
rename for clarity (matches §3) and add the time-axis version? argument |
no neighbours (only backlinks + forward_links) |
neighbours(page_id, direction, link_skill) |
unify into one symmetric primitive |
no validate |
validate(content, as_page_id?) |
required for the authoring feedback path (H-AUTHORING-1 engine path); cheap to add |
| no write path | open_session/apply_op/close_session (live) + update_page (fallback) |
full CRDT op-stream write per locked decision (1); whole-page fallback for environments without CSP fix |
| no stored-query tool over MCP yet (only Python) | expose it with parameter binding (shipped as run_stored_query, since consolidated into query_instance) |
the origin axis depends on it; parameters per locked decision (2) |
no escurel meta-skill in the corpus |
add it, mandatory + auto-shipped | per locked decision (3); the agent has no in-corpus documentation of the tool surface today |
search returns blocks only |
search(..., granularity='block'|'page') |
both granularities exposed per locked decision (4); block remains the default |
None of these are large lifts. The biggest addition is the escurel
meta-skill, which is just one markdown file.
Status (delivered). Every row above now ships in the Rust gateway:
neighbours,validate, the CRDT write path +update_pagefallback, parameterised query execution over MCP (first asrun_stored_query, now consolidated intoquery_instance— the 2026-08-14 surface consolidation), the mandatory auto-shippedescurelmeta-skill, andsearch(granularity='block'|'page')with the frontmatterfilter.
The four open questions above were resolved in the design session:
-
Write path: full read+write via CRDT op stream over MCP. The agent participates in the same CRDT layer as the web client. Operational consequence: this design has a hard dependency on the Claude.ai CSP fix (
anthropics/claude-ai-mcp#40, see HYP-D1). For environments where the CSP fix is not yet available, the whole-pageupdate_page(...)fallback is preserved as a degraded mode. See §"Write path in detail" below. -
Query execution is parameterised (locked for the then
run_stored_query; carried by today'squery_instance). Query instances declare aparams:schema in frontmatter; the dispatcher binds parameters safely. See §"Query parameters in detail" below. -
The
escurelmeta-skill is mandatory and auto-shipped. The indexer shipsskills/escurel.mdwith every new tenant. Tenants may extend the page with tenant-specific guidance (appended after the standard sections) but cannot delete it or remove the standard sections. -
searchreturns blocks OR pages, agent chooses via flag. Default isgranularity='block'(matches HYP-E2 and the real-LLM run);granularity='page'is available for tasks where the agent wants page-level summary hits.
The tool surface table above is updated to reflect these decisions; the detail sections below specify the parts that need it.
The agent's write surface is three CRDT-aware tools plus the whole-page fallback:
| tool | inputs | output | mode |
|---|---|---|---|
open_session |
page_id: str |
{session: str, head_version: str, content: str} |
live |
apply_op |
session: str, op: CRDTOp |
{ok, conflicts?: [...]} |
live |
close_session |
session: str, commit: bool = true |
{final_version, issues} |
live |
update_page |
page_id: str, content: str, base_version?: str |
{ok, issues, new_version} |
fallback |
Live mode (the default once the CSP fix lands). The agent
calls open_session(page_id) to get a CRDT session token plus
the current head state. The agent then issues apply_op(...)
for each granular edit (insert / delete / move). Concurrent
edits from other actors are merged by Loro (B1 [verified]); the
agent receives merged content on each apply_op response and
can adapt mid-stream. close_session(commit=true) materialises
the final state to canonical markdown and triggers
update_page-equivalent indexer work.
The op schema is whatever Loro emits — opaque to the agent
in detail but always parseable as one of {insert, delete, move, mark, unmark} against a block path. The agent generally
does not handcraft ops; the typical pattern is for the agent
to receive an op stream from the harness's text-edit tool
(which generates ops from agent intent) and forward them.
Fallback mode. Where the CSP fix is not deployed (today,
in many MCP clients), the agent calls update_page(page_id, content, base_version=<v>) with the full markdown body and
the version it based its draft on. The server diffs against
current CRDT state, produces the op set, and applies. If
concurrent edits have changed base_version, the server
either auto-merges (Loro's CRDT guarantee) or returns
{ok: false, issues: [{code: 'conflict', ...}]} for the
agent to retry with the new content.
Validation runs on every write. Both apply_op and
update_page enforce the index-time checks (skill exists,
target exists, anchor exists, frontmatter required keys
present). An error-severity issue rejects the op or page;
warning-severity issues commit but appear in the response.
The issue codes are the ones H-AUTHORING-1 verified.
Awareness is a separate channel. Viewer presence and cursor positions (per C-9 Model B) do not flow through these tools. They use a polled heartbeat over the existing MCP transport, not the CRDT op stream.
A [[query::*]] instance declares its parameter schema in
frontmatter:
---
type: instance
skill: query
id: customer-churn-trend
db: ext
params:
- {name: customer_id, type: text, required: true}
- {name: from_date, type: date, required: false, default: '2026-01-01'}
- {name: to_date, type: date, required: false}
sql: |
SELECT as_of, score
FROM ext.customers.churn
WHERE customer_id = :customer_id
AND as_of >= :from_date
AND (:to_date IS NULL OR as_of <= :to_date)
ORDER BY as_of
---Calling pattern:
query_instance("customer-churn-trend",
params={"customer_id": "acme-corp"})
→ {rows: [...], schema: [...], truncated: false}
Type enforcement happens at the dispatcher: parameters are bound as typed values (DuckDB prepared statements), never as string interpolation. This closes the SQL-injection class that H-SECURITY-1 covered for stored SQL but did not exercise for parameter binding.
Missing required parameters return
{error: 'missing_required_param', name: 'customer_id'}
without dispatch. Extra parameters return
{error: 'unknown_param', name: 'xyz'} — strict matching to
the declared schema.
The (reserved, not yet emitted) snapshot_version field is populated only for queries that
hit a versioned external table (DuckLake). It pins the
agent's view; if the agent caches the result and wants the
same view later, it can pass as_of_snapshot=<version> to a
follow-up call to guarantee the same data.
Two granularities, same search tool:
search(q, k=10, granularity='block', page_type?, skill?)
→ [{page_id, anchor, snippet, skill, page_type, score}, ...]
search(q, k=10, granularity='page', page_type?, skill?)
→ [{page_id, best_anchor, snippet, skill, page_type, score,
block_count}, ...]
Block granularity (default) is what the real-LLM run used: each hit pinpoints a specific block within a page. Good for H5-style citation retrieval ("which block in which page mentions Porter stemming").
Page granularity collapses adjacent block hits into one per-page row, with the best-scoring block returned as the snippet. Good for "enumerate the pages broadly related to X" tasks where the agent doesn't need a specific block.
The choice is recorded in the response so the agent (and any caching layer) can tell them apart.
The mandatory escurel skill adds one entry to every tenant's Tier
1 catalogue. Measured cost: ~180 tokens for the skill page's
(id, description) line in list_skills. The skill body
(the larger doc, ~6 k tokens) loads on demand via expand —
exactly like every other skill body. Net effect on the Tier 1
budget arithmetic from HYP-D2: negligible (~0.3%).
Two questions deferred because they're more about deployment than design contract:
- CRDT op stream over MCP transport: is the right shape
request/response (each
apply_opis one HTTP call) or bidirectional streaming (one long-lived call carries many ops)? Streaming is more efficient but few MCP clients implement it today. Recommended: ship request/response first, add streaming when there's a measurable benefit. - Subscription / awareness channel: does the same MCP server host both, or is the awareness channel a separate endpoint? Single endpoint is simpler operationally but couples the two concerns. Separate endpoint matches the C-9 recommendation that awareness is a cheaper channel than data merge.
These can be punted to implementation.