HTTP is the sole transport surface. Both transports expose the same
logical surface — the agent tools and the admin endpoints. They
differ only in framing and streaming model. The tool
semantics are the contract in
../contract/agent-interface.md;
this doc specifies the wire shapes.
| transport | mount | what it carries | streaming | default for |
|---|---|---|---|---|
| MCP-over-HTTP | /mcp |
JSON-RPC 2.0 framed as MCP method calls; one HTTP request per call; long-running calls block until done and return the final result | none (blocking) | agents, MCP clients, CLI/TUI, admin/operator tools |
| WebSocket | /ws |
Bidirectional. Used for live CRDT op streams, presence pings, and search-result streaming | full-duplex | live mode, web client |
Alongside the two tool transports the server also mounts a set of
plain HTTP endpoints (no JSON-RPC framing): GET /openapi.json
(an OpenAPI 3.1 document generated from the same tools/list
payload, for non-MCP HTTP clients), the liveness/readiness probes
GET /healthz + GET /readyz, GET /version, GET /metrics (a
Prometheus scrape, optionally on a dedicated listener), and the
two document-ingest routes POST /ingest + POST /ingest/upload
(see Instance backends).
Execution labels (WI-8 / REQ-LABEL-01). Every tools/list entry
carries an additive execution: "deterministic" | "orchestration"
label. deterministic = the result is a pure function of KB state +
arguments (reads, queries, validation, bundle builds); orchestration
= the call advances loop state (writes, events, sessions, lifecycle).
The default for a new tool is orchestration (fail-closed: nothing
masquerades as deterministic compute by omission). This makes the
interlocked-loops "deterministic-first" invariant machine-visible: a
per-phase tool surface can hand a compute step deterministic tools
only.
Auth is the same on both (OIDC Bearer in Authorization
header; see platform.md). Tenant resolution
is the same (one tenant per token claim). Quotas apply uniformly.
Default exposure. MCP/HTTP and WebSocket are designed for
ingress behind a reverse proxy + authentication terminator
(the proxy may also terminate TLS). The choice of which transports
a particular deployment exposes is per-target; see
../deploy/substrate.md §5
for the substrate-target binding (typically internal/tailnet-only
via kamal-proxy, declared in apps/registry.yml).
These are referenced from every tool, expressed as JSON Schema.
{
page_id: string, // ULID, canonical id
slug: string | null, // mutable human-friendly slug
skill: string, // the skill id this page declares or is an instance of
page_type: "skill" | "instance"
}{
page_id: string,
slug: string | null,
skill: string,
page_type: "skill" | "instance",
anchor?: string, // only for granularity=block
snippet: string,
score: number, // RRF-fused (or rerank score when reranking is on)
similarity: number, // raw vector cosine similarity of the hit
frontmatter_excerpt: { [key: string]: any } // includes description and at if present
}{
skill: string | null, // null for bare [[id]]
id: string | null, // null for bare [[skill]]
anchor: string | null,
version: string | null,
alias: string | null
}{
severity: "error" | "warning",
code: string, // e.g. "unknown_skill", "anchor_missing", "frontmatter_required_key_missing"
location: string, // e.g. "line:32 col:8" or "frontmatter.at"
message: string,
suggestion?: string
}validate reports every finding it has. update_page refuses a write only
for a deliberately narrow subset — link integrity and page identity — so
that seeding and forward references keep working; see the filter in
tool_update_page.
frontmatter_autonomy_unknown (error, frontmatter.autonomy, skill pages
only) is the one code whose blocking is operator-controlled, via
ESCUREL_AUTONOMY_LINT = off (default) | log | enforce. autonomy:
was unvalidated free-form frontmatter before it was recognised, so blocking
on it by default would make a page that already carries a junk value
unwritable — for any edit, not only an edit to that field. validate
reports it in all three modes; log additionally warns to the operator log
while writing, and leaves the response identical to off.
type FilterValue =
| string | number | boolean | null
| { ">=" : any } // operator-wrapped value
| { "<=" : any }
| { ">" : any }
| { "<" : any }
| { "in" : any[] }
| { "not": any } // value-level negation
type Filter = { [frontmatter_key: string]: FilterValue }This filter syntax is intentionally minimal — agents express
date-range queries ({at: {">=" : "2026-04-01"}}),
enumerations ({status: {in: ["open", "in_review"]}}), and
null-checks ({prev_review: null}) without learning SQL. The
dispatcher translates the filter to a DuckDB prepared statement;
unknown keys, unknown operators, or type-mismatched values
return Issue rows rather than dispatching.
The agent tools, grouped by axis. Inputs and outputs given as JSON
Schema. (The full non-admin agent surface is ~22 tools — the read /
write / event / session tools below plus fetch_blob, query_instance,
write_instance, list_snapshots, append_message, list_messages;
admin/operator tools are in Admin surface.)
Several read tools share two optional overlay/time-travel params:
scenario(string) — a what-if overlay. Absent/null reads the base corpus only; a named scenario readsbase ∪ overlay, the overlay winning per slug. Accepted byexpand,resolve,neighbours,search, andlist_instances.as_of(RFC 3339 string) — a time-travel cut; state/edges/ blocks born after it are excluded (see the M7 note underexpand). Accepted byexpand,neighbours,search, andlist_instances.
Provide exactly one of q / queries (at least one non-empty string).
filter is the FilterClause shape. It is
applied after vector + FTS retrieval as a metadata
post-filter, so it doesn't degrade recall; only the response is
narrowed. Useful for "find recent meetings about Acme":
search(q='Acme', skill='meeting', filter={at: {">=": "2026-04-01"}}).
// request
{ "wikilink": "[[customer::acme-corp]]", "scenario": null }
// response
{
"parsed": WikilinkParsed,
"page": PageRef, // or null if not found (or ACL-denied)
"exists": true
}Returns the parsed link plus the resolved page metadata.
resolve does NOT fetch the body — that's expand.
// request
{ "page_id": "01HXMQ...", "as_of": null, "scenario": null, "full": false }
// response
{
"page": PageRef,
"frontmatter": { ... }, // full frontmatter
"body": "...markdown body...",
"blocks": [ { "anchor": "blk-acme-signals", "content": "..." }, ... ],
"wikilinks_out": [ WikilinkParsed, ... ],
// external-backend instances only (see Instance backends):
"backend_projection": null, // sql_view: { view, rows, source, truncated, issue? }
"chunks_total": null, // document: total chunk count
"chunks_truncated": false // document: blocks are a bounded lead of chunks_total
}scenario is the what-if overlay and as_of the time-travel cut
(shared read-tool params, above). full (bool, default false)
is a document-instance knob: when true expand returns every
chunk block (the single-document detail / heatmap view) instead of
the bounded lead — chunks_truncated is then always false.
Not yet implemented. An earlier draft returned a
snapshot_version field (the replayed CRDT snapshot marker); the
current builder never emits it. Use list_snapshots to enumerate an
instance's replayable taken_at points.
Historical state (M7). as_of = T (RFC 3339) reconstructs the
instance as it was at T: when the page has a CRDT snapshot taken
at-or-before T, that snapshot is materialized + re-parsed (so the
returned frontmatter/body are the historical values — the
projection of its events up to T). A page with no snapshot history at-
or-before T falls through to the at_ts birth filter (returns the page
when born, null when not). This extends the v1 rule that markdown
instances ignore @version silently — markdown instances with a
seeded snapshot history now honour the time cut.
-
list_snapshots(read) —{page_id}→{snapshots: [taken_at, …]}, the RFC-3339 timestamps of an instance's snapshot history, oldest first. These are the discrete pointsexpand(as_of=T)can replay — the "state over time" version markers in the instance view. Follows the page's own read ACL, exactly likelist_op_authors: denial is absence, not error — a page you may not read reports the same empty history as a page that does not exist (no existence oracle). -
list_op_authors(read) —{page_id}→{page_id, ops: [{op_id, hlc, applied_at, principal}, …]}, oldest first: who wrote each live-editing op on the page (escurel#357 / CR-6).principalis the subject the gateway verified for theapply_op(or WSopframe) that carried it — not the Loro peer id in the op payload, which identifies a device rather than a person.nullfor ops applied before the gateway recorded a principal. Ops already subsumed by a snapshot and swept bycompact_lanesare gone, so this is the retained tail of the history. Returns no op bytes.Gated on the page's own read ACL (
may_read_instance, the predicateexpand/search/list_instancesshare): authorship is metadata about the page, and in a shared tenant "who touched this instance" is exactly what an instanceacl:block exists to keep inside its engagement. Denial is absence, not error — a denied caller gets the empty history a page with no ops returns, so the refusal is not an existence oracle.The page-level counterpart is
expand'spage.last_written_by: the verified principal behind the page's most recent whole-page write.nullon anas_ofread — a CRDT snapshot stores document bytes, not an author, and reporting the current writer against a past state would be a plausible-looking lie.
For events: expand returns the full body of an event instance
including any narrative text and follow-up links. Anchor support
is the same as any other instance.
// request
{ "page_id": "01HXMQ..." }
// response
{
"blob": { // or null (see below)
"page_id": "01HXMQ...",
"content_type": "application/pdf", // sniffed (pdf / docx / pptx / xlsx / text)
"size": 104857,
"bytes_base64": "JVBERi0x..." // the original retained file bytes
}
}Returns the original retained file behind a document-backed
instance (the blob named by backend_ref.blob_id), base64-encoded with
a sniffed content type, for a faithful client-side preview of the
source document. blob is null for a non-document page, a missing
page, or an instance the caller may not read (ACL-mirrored on expand;
existence is not leaked). The transfer is capped at 25 MiB.
// request
{
"page_id": "01HXMQ...",
"direction": "both", // "in" | "out" | "both"
"link_skill": null, // optional single skill filter
"as_of": null, // optional RFC 3339 time-travel cut
"scenario": null // optional what-if overlay
}
// response
{
"edges": [
{
"src_page": "01HX...", // source page id
"dst_page": "01HY...", // destination page id
"link_skill": "meeting",
"link_version": null,
"dst_anchor": null // anchor on the destination, if the link targets one
},
...
]
}Edges whose other endpoint is an owner-private instance the caller cannot read are dropped (fail-closed ACL).
Not yet implemented. The request keys link_skill_in (a
multi-skill array) and order_by (a <field> <asc|desc> sort on the
target's frontmatter), and the edge fields anchor /
src_anchor / target_frontmatter_excerpt, are not accepted/emitted
by the current dispatcher — a caller that sends the extra request keys
has them silently ignored.
Bounded, read-only traversals over the provenance graph
(ADR-0010). Where
neighbours is one-hop, these walk multi-hop paths over the derived
resolved_links view (which resolves each wikilink's slug to a real
page_id and exposes the relation kind — the frontmatter field name,
e.g. derived_from, motivated_by). They are parameterized and
bounded — the agent supplies named scalars and an allow-listed
relations filter, never SQL — and every returned instance is
ACL-filtered fail-closed (a hop/row that crosses an owner-private page
the caller can't read is dropped; a path query through a private
node reports reachable: false with no path, never leaking existence).
max_hops is clamped server-side (≤ 12). The engine runs on stock
DuckDB recursive CTEs; a DuckPGQ MATCH backend sits behind a reserved
seam, gated on the extension becoming available for the pinned DuckDB
(currently unavailable — see the discovered note).
// provenance_ancestry — "everything this rests on" (up) / "…derived from it" (down)
// request
{ "page_id": "markdown/instances/result/gbm-auc.md",
// aliases: from_page / from_page_id
"direction": "up", // "up" (default) | "down"
"relations": ["produced_by","uses"], // optional allow-list; empty = all kinds
"max_hops": 5, // optional, default 5, capped at 12
"as_of": null, // optional RFC 3339 source-birth cut
"to_page": null } // optional destination (alias: to_page_id)
// response (no to_page — the classic walk)
{ "hops": [ { "page_id": "…/analysis/gbm-run.md", "skill": "analysis",
"relation": "produced_by", "depth": 1 }, ... ] }
// provenance_ancestry with to_page — shortest path / reachability between two pages
// request
{ "page_id": "…/result/r.md", "to_page": "…/dataset/d.md",
"direction": "up", "relations": ["derived_from"], "max_hops": 5 }
// response
{ "reachable": true, "path": ["…/result/r.md", "…/analysis/a.md", "…/dataset/d.md"], "depth": 2 }
// provenance_report — cross-graph reports; kind selects the report
// request: { "kind": "drift", "skill": null } // kind: "drift" | "abandoned"
// // (unknown kind → -32602);
// // skill optionally restricts
// response (kind = "drift") — decisions resting on a since-superseded expectation
{ "kind": "drift",
"rows": [ { "decision_page_id": "…/decision/ship.md", "decision_skill": "decision",
"expectation_page_id": "…/expectation/churn-v1.md",
"superseding_page_id": "…/expectation/churn-v2.md",
"decided_at": "2026-02-06T09:00:00Z",
"superseded_at": "2026-03-02T10:00:00Z" }, ... ] }
// response (kind = "abandoned") — nodes retired by supersession/abandonment
// { "kind": "abandoned",
// "rows": [ { "page_id": "…/expectation/churn-v1.md",
// "skill": "expectation", "via": "supersedes" }, ... ] }(Before the 2026-08-14 surface consolidation these were four tools:
provenance_path is now provenance_ancestry + to_page, and
expectation_drift / abandoned_paths are provenance_report kinds —
note the abandoned rows moved from a nodes key to rows.)
provenance_report(kind: "drift") is the cross-graph "lost context" query: a
decision whose motivated_by/addresses expectation was later
replaced by a supersedes revision authored after the decision
(superseded_at > decided_at). These tools are available for any tenant;
they read whatever provenance relations exist and are most meaningful
with the project-memory skill pack subscribed.
// request: {}
// response
{
"skills": [
{
"id": "customer",
"description": "...",
"required_frontmatter": ["tier", "opened", "status"],
"optional_frontmatter": ["mrr_band", "owner", ...],
"is_event_typed": false, // true iff `at` is in required_frontmatter
"autonomy": "review", // omitted when undeclared OR unrecognised
"params": [ // omitted entirely when none are declared
{ "name": "window", "kind": "string", "required": true,
"label": "Window", "description": "e.g. 30d" },
{ "name": "depth", "kind": "integer", "required": false }
],
"backend": { "kind": "markdown" },
"capabilities": { "writable": true, "granularity": "block",
"search": "hybrid", "supports_crdt": true }
},
{
"id": "erp_customer",
"description": "...",
"required_frontmatter": [...],
"optional_frontmatter": [...],
"is_event_typed": false,
"backend": { "kind": "sql_view" }, // or "document"
"capabilities": { "writable": false, "granularity": "page",
"search": "late_materialized", "supports_crdt": false }
},
...
]
}is_event_typed is a derived convenience flag (true iff at is
in required_frontmatter); the agent does not need to compute
it from the field list.
The catalogue is caller-scoped, and the acl block is admin-only
(#374). Like every other read verb, list_skills filters: a skill whose
declared acl.read does not intersect the caller's effective groups is
absent from the response — denial as absence, never an error, so a
client can trust the catalogue instead of re-filtering it. Two narrowings
keep this from making skills into access-control containers: a skill with
no acl: block falls through to the tenant default (read: [public] as
shipped) and stays visible to everyone, and the structural owner group is
treated as satisfied, so the legacy visibility: owner mapping
(acl.read: [owner]) never hides a type whose instances are private.
The admin role bypasses, as everywhere.
The acl object itself is projected only to an admin caller. Group
names are authorisation metadata, not schema: in a shared tenant they are
named per engagement, so handing every token holder the grant list would
disclose the customer roster and the authorisation topology. A non-admin
row omits acl entirely — indistinguishable from a skill that declares no
block. visibility and owner_field are retained for every caller: they
describe how instances behave and name no group.
autonomy reports the human-in-the-loop policy the skill page declares via
its autonomy: frontmatter key — auto (a write derived from this skill
commits directly), review (held for human approval), or confirm (as
review, plus an out-of-band notification). Escurel does not enforce the
policy; the gateway stays automation-free. It recognises the key so a
consumer has one declared place to read it from, and so validate can
object to a typo at authoring time.
The field is omitted when the key is absent and when its value is not
one of the three. Those two cases collapse on purpose: an unrecognised
value must never be reported as auto, because a consumer reading auto
switches a human gate off — autonmy: review silently meaning "commit
ungated" is a data-governance incident rather than a bug. A client treats
an omitted autonomy as "hold for review" and calls validate to learn
which of the two cases it is. Case and surrounding whitespace are
normalised, so Auto and " auto " are both auto.
required_frontmatter is the shape of the instances a skill produces.
params is the shape of what one run of the skill takes. For an
instance-creating skill the two nearly coincide, which is why the
distinction went unremarked for so long; they part company as soon as the
skill's job is not "make one instance shaped like this" — a report skill
parameterised by window and grouping, a workflow skill taking a target and a
depth budget, an analysis skill taking two instances to compare.
Escurel does not execute skills and never binds these values. It reports what the page declares, so a client can build an input form from the catalogue alone without expanding every page.
A skill page declares them either as a sequence (the params: idiom
query pages already use, and the only form that preserves the author's field
order):
params:
- {name: window, kind: string, required: true, label: Window,
description: 'e.g. 30d'}
- {name: grouping, kind: string}
- {name: depth, kind: integer}or as a mapping of name to attributes, whose order is the frontmatter's key order rather than the author's:
params:
window: {kind: string, required: true, description: 'e.g. 30d'}kind is reported as one of string | integer | boolean — deliberately
exactly the A2UI form field kinds, so the catalogue renders with no mapping
layer at the consumer. text / int / bool are accepted as synonyms in
frontmatter (that is the spelling authors arrive with from the query-page
params: block, where type: names a richer, SQL-bound vocabulary), as is
type: in place of kind:. Case and surrounding whitespace are normalised.
required: defaults to false; label and description are omitted rather
than emptied when undeclared, so a client falls back to name.
A kind: the server cannot read degrades to string; the parameter is
still reported. This is the opposite of the autonomy: rule above, and
deliberately so. There, dropping the value is the safe direction — only an
explicit auto may switch a human gate off. Here, dropping the parameter
would delete a possibly-required field from a generated form; the run would
then be invoked without it and fail with nothing on the page to explain why.
An over-permissive text box under-validates, a missing box loses data.
validate reports the mis-declaration as a warning
(frontmatter_param_kind_unknown) so the author still learns, without
failing a write for a key that has never been validated. A params: block
that is neither shape, or an entry with no name:, is an
error (frontmatter_params_malformed) — there is nothing to degrade to
when a parameter has no name to be passed under.
params is omitted from the response entirely when a skill declares
none, so every skill page written before this key existed has a
byte-identical row. The key is read on SKILL pages only: on an instance page
params: is already taken by [[query::*]] pages and is untouched.
backend.kind (markdown | sql_view | document) and the
capabilities object tell the agent where a skill's instances live and
what may be done with them — see Instance backends.
A skill that declares no backend: block is markdown with the writable,
block-grain, CRDT capabilities above (the historical default, so existing
clients are unaffected).
A skill page MAY declare a read policy for its instances:
visibility: owner # public | owner (default: public)
owner_field: credential # frontmatter field naming the owning principalvisibility: public(the default, and the only behaviour before this field existed) — any authenticated caller in the tenant may read an instance of this skill.visibility: owner— an instance is readable only by its owning principal or the admin role. The owner is the verified tokensubthat equals the instance'sowner_fieldvalue: either a direct value (e.g.credentialcarries the platformsub) or a[[skill::id]]wikilink, resolved to the linked instance'scredential.
Enforcement is deterministic and applied on every read path:
expand of a non-owned owner-instance returns {"page": null} (absence,
not an error — existence is not leaked); list_instances and search
filter out non-owned owner-instances; the admin role bypasses. The
decision is a pure comparison on the request path — never an LLM, agent,
or classifier. Owner-visibility is reported back on list_skills as
"visibility" + "owner_field".
A skill MAY instead declare a per-CRUD group ACL — a superset of
visibility/owner_field (see
ADR-0004):
owner_field: author # still drives the `owner` group
acl:
read: [public]
create: [owner]
update: [owner, moderator]
delete: [admin]Each verb lists group names. public / owner / admin are
reserved special groups (always-present / structural-owner /
verified-admin respectively, and never grantable via a token claim or a
membership row); any other name is a custom group, satisfied when it
is present in the caller's groups_claim JWT array or in the
DuckDB-canonical group_members table. An action is allowed iff the
caller's effective group set intersects the verb's list (admin always
bypasses); empty intersection → deny (fail-closed, no deny rules in v1).
A verb omitted from the block, or a skill with neither acl: nor
visibility:, falls through to the tenant default — the
acl_defaults: block on the escurel meta-skill page, or, when unset,
the shipped default (read:[public], writes [admin]) that reproduces
the pre-RBAC behaviour. A legacy visibility: field with no acl: block
maps deterministically (public → open read + admin writes; owner →
owner-all), so existing pages are unchanged. delete is enforced as
update in v1 (there is no distinct delete operation at the write
boundary). Membership is mutated by the admin-only add_group_member /
remove_group_member / list_group_members tools. list_skills reports
the resolved block as "acl" to an admin caller only, and filters the
catalogue by the skill's acl.read for everyone else (#374 — see
list_skills); visibility/owner_field are retained for
every caller. (Capability-tool RBAC is phase 2.)
An instance page MAY carry its own acl: block, in exactly the same
shape. It is resolved per verb, most specific first: the instance's
block → the skill's block → the tenant default → deny.
# markdown/instances/customer_note/hoffmann-1.md
type: instance
skill: customer_note
id: hoffmann-1
acl:
read: [engagement-hoffmann]
update: [engagement-hoffmann]This is what lets two instances of one shared skill be readable by two
different groups — the account/engagement shape, where the unit of access
is the record and not its author (which is all owner_field could
express). Enforcement is the same fail-closed predicate on the same
paths: expand, search, list_instances, resolve, neighbours,
query_instance, the WebSocket attach, and the event bus (a filed event
is exactly as visible as its instance). Denial is absence, never a
distinguishable error.
Two deliberate limits:
- An instance block is consulted per verb. Declaring only
read:narrows reads and leavesupdate/createfalling through to the skill. Narrowing a write means declaring the write verb too. - On a write, the block that decides is the one on the stored
page, not the one in the incoming content — otherwise a caller could
authorise its own write by shipping a block that grants itself. A
createhas no stored page and therefore stays skill-grained: thecreategrant is "may add instances of this type", a skill-level claim. (A caller who mayupdatea page may, as everywhere, rewrite itsacl:block.)
An instance with no acl: block resolves exactly as before, so every
page authored before this existed is unaffected and the change needs no
rollout flag of its own. The write half remains gated by
ESCUREL_WRITE_ACL.
// request
{
"skill_id": "meeting",
"frontmatter_key": "source", // optional single-field equality filter…
"frontmatter_value": "gmail", // …both must be present to apply
"order_by": "at desc", // optional; "at asc" | "at desc" only
"limit": 50,
"as_of": null, // optional RFC 3339 time-travel cut
"scenario": null // optional what-if overlay
}
// response
{
"instances": [
{ "page_id": "01HX...", "skill": "meeting",
"frontmatter": { "at": "...", "with": "...", ... },
"at": "2026-04-12T10:00:00+02:00" }, // the typed `at`, or null
...
],
"next_cursor": null // string = more rows; null = done
}next_cursor is an opaque resume cursor: a string when rows lie past
the page — pass it back as cursor to continue — and null on the
final page. Only null means done; the ACL filter runs after
limit and can shorten any page. An undecodable cursor is -32602.
This is the event-log primitive. Unlike search, list_instances
does not accept the operator-wrapped FilterClause object — its
only filter is the single frontmatter_key = frontmatter_value
string-equality pair. order_by is restricted to at asc / at desc.
Owner-private instances the caller cannot read are filtered out.
The one query surface. (The legacy admin-gated run_stored_query
tool — pre-declared arbitrary SQL over the whole corpus, with no
per-row owner to ACL against — was removed in the 2026-08-14 surface
consolidation; query_instance accepts query_id as an alias for
ref, so old callers keep their argument spelling. For event-volume
queries that exceed the markdown-friendly scale (~1 M), operators move
the event records to an external DuckLake table and the agent reaches
them through a [[query::*]] page via query_instance instead of
list_instances — see
storage.md.)
A parameterised, full-result-set read over one sql_view instance's
view. The query page
(a [[query::*]] instance) declares a target: [[skill::id]] naming the
sql_view instance and references its view via the {{target}} placeholder:
# markdown/instances/query/sales-by-category.md
type: instance
skill: query
id: sales-by-category
target: "[[sales::eu-2026]]" # the sql_view instance to read
params:
- {name: min, type: number, required: true}
sql: "SELECT category, SUM(amount)::BIGINT AS total
FROM {{target}} WHERE amount >= :min GROUP BY category"// request // response
{ {
"ref": "sales-by-category", "rows": [ { "category": "hw", "total": 50 } ],
"params": { "min": 10 } "schema": [ { "name": "total", "type": "BIGINT" } ],
} "truncated": false
}ref is the query id or its [[query::id]] wikilink. Two trust boundaries
are kept separate by construction:
- Value position — every
:paramruntime value is bound as a positional DuckDB prepared-statement parameter, so injection through a param value is impossible and it never flows through thesql_viewfilter-interpolation path. - Identifier position —
{{target}}resolves to the target's managedvw_…view name, allow-listed through the samevw_-prefix guard the projection path uses (never a bound value).
Access: query_instance is an agent tool
gated by the per-instance read ACL on the target instance
(may_read_instance, fail-closed): the caller must be allowed to read the
underlying data, not merely the query template. Admin bypasses; a denied
caller gets an authorisation error. The result set is capped at
MAX_RESULT_ROWS (10 000) with truncated set when the cap clipped the tail.
// request
{ "content": "---\nskill: meeting\n...\n---\n# ...", "as_page_id": null }
// response
{ "ok": true, "issues": [Issue, ...] }ok is false iff any Issue is error-severity (warnings do not fail
a draft); the full issues list is always returned.
// open_session request: { "page_id": "01HX..." }
// open_session response: { "session": "sess_...", "head_version": "v42",
// "ws_url": "/ws" }
//
// apply_op request: { "session": "sess_...", "op": "<base64 Loro op bytes>" }
// apply_op response: { "ok": true, "merged_version": "v43" }
//
// close_session request: { "session": "sess_...", "commit": true }
// close_session response: { "ok": true, "final_version": "v50", "issues": [] }op is base64-encoded Loro op bytes. ws_url is the relative /ws
path (the gateway does not know its public origin, so it never emits a
full wss:// URL). Not yet implemented: open_session does not
return the page content, and apply_op returns neither content,
conflicts, nor issues — a client reads the merged document over the
WS channel or via expand.
The ws_url returned by open_session is the recommended
channel for apply_op — the WS path delivers ops with lower
overhead than HTTP. MCP-over-HTTP clients without WS may
continue calling apply_op over HTTP.
Write ACL. The session surface enforces the same write policy as
update_page (ESCUREL_WRITE_ACL): open_session refuses a caller
who may not write the page (JSON-RPC -32000, data code forbidden —
a session's op stream would edit the page byte by byte), and
close_session re-checks that policy at commit time, since the ACL
can change while a session is open. A refused commit returns
update_page's denial shape ({ok: false, issues: [{code: "forbidden", …}]}) and leaves the session open, so the caller can
still discard with commit: false. apply_op itself stays keyed by
session possession (session ids are unguessable); the open and close
gates are what hold.
// request
{
"page_id": "01HX...",
"content": "---\n...\n---\n# ...",
"base_version": "v42" // optional; required only if the client knows it
}
// response
{
"ok": true,
"new_version": "v43",
"auto_merged": true, // true iff a stale base_version was three-way-merged
"issues": [Issue, ...]
}If base_version is supplied and the head has advanced, the
server attempts a CRDT-aware three-way merge (Loro): it
reconstructs the base snapshot the client branched from, forks
it into the head and incoming edits as concurrent Loro branches,
and unions them. A clean merge is persisted and the response
carries auto_merged: true. The server refuses to persist a
merge that no longer parses or whose frontmatter matches neither
side (both sides changed the same key) — that is an unresolvable
conflict: {ok: false, issues: [{code: "conflict", ...}], head_content: "..."}, and the client re-drafts against
head_content. (Auto-merge needs the base snapshot; a
base_version older than the first update_page snapshot, or a
bare session op-count with no snapshot, always conflicts.)
update_page (and apply_op) against an instance whose skill is a
non-writable backend (sql_view, document) is rejected with
{ok: false, issues: [{code: "backend_read_only", ...}]} — the external
source/blob is canonical and is never written back through the page API.
See Instance backends.
update_page against a base-layer page — one whose stored
frontmatter carries layer: base@<pack>@<version>, i.e. it was imported
from a subscribed skill pack — is rejected with {ok: false, issues: [{code: "layer_read_only", location: "frontmatter.layer", ...}]}
(REQ-LAYER-02). The guard keys off the stored page's layer, so
stripping the layer: field from the draft is not an unlock; a draft
declaring layer: base@… is rejected the same way (base pages are
created by pack import only, never by update_page). open_session on
a base-layer page fails with a JSON-RPC -32000 error whose message
starts layer_read_only: — live CRDT co-authoring must not bypass the
guard. Pages without a layer: field (every pre-layer page) and pages
declaring layer: overlay are unaffected. list_skills reports each
skill's layer ("overlay" default, or the base@<pack>@<version>
pin) so agents and operators can tell stable from editable.
Shadowing (REQ-LAYER-03). A tenant overlay skill page MAY declare
the same skill id as an imported base page — that is how a tenant
specialises pack content without forking it. Page-level precedence
with drift visibility: resolve prefers the overlay; list_skills
reports ONE entry per skill id (the overlay) with an additive
shadows: "base@<pack>@<version>" pin; expand of the shadowing
overlay carries an additive shadow object —
{base_page_id, pack, base: {…the base page's frontmatter…}} — so the
base values stay visible, never silently masked (the same namespacing
discipline as the sql_view source object). The base page itself is
untouched (INV-SHADOW): expanding it directly returns the pack's
pristine content, and a future pack upgrade rebases against it.
import_pack therefore lands a base skill beneath an existing tenant
skill of the same id (the overlay direction of pack_skill_collision
no longer refuses; two BASE pages with one id still do — no precedence
exists between packs).
Events are the dynamic input of the memory triad (Events · Skills ·
Instances). They live in a dedicated events store (not pages); each
event's label_skill links to the skill that knows how to process it,
and instance_page_id links to the instance it belongs to once
processed. The inbox is the status = 'inbox' view. All four tools are
MCP tools over POST /mcp with the usual quota debits
(capture_event/assign_event = Writes; list_inbox/list_events =
Queries).
capture_event(write) — append an event to the inbox. Input:{event_id?, at?, source?, mime?, label_skill?, instance_page_id?, title?, body?, provenance?}(event_idis a server ULID when absent;instance_page_idonly pre-flags a candidate — the event stays in the inbox untilassign_event). Returns the stored event ({event_id, at, status: "inbox", …}).list_inbox(read) —{limit?, cursor?}→{events: [Event, …], next_cursor?}, unprocessed events, newest first.list_events(read) —{instance_page_id, limit?, cursor?}→{events: [Event, …], next_cursor?}, that instance's processed event history, oldest first (the sequence whose projection is its state).
Both listings paginate with an opaque cursor: next_cursor is present
iff rows lie past the page — pass it back as cursor to continue.
Only its absence means the listing is complete; a short page never does
(the per-event ACL filter runs after limit and legitimately shortens
pages). An undecodable cursor is -32602 invalid_params.
assign_event(write) —{event_id, instance_page_id}→ marks the eventprocessedand bound to the instance. This is the (external) agent's act of folding the event into state.
An Event is {event_id, at, source, mime, label_skill, instance_page_id, status, title, body, provenance}.
Per-event ACL (ESCUREL_EVENT_ACL: off | log | enforce,
default off). An inbox event is unreviewed third-party text, so on a
shared tenant the event surface is scoped per caller, not just per
tenant. The rule: an event's visibility follows the record it belongs
to; until it belongs to one, it follows the person who captured it.
capture_eventstamps the verified caller subject into the stored event asprovenance.captured_by, overwriting any caller-supplied value under that key. This happens in every mode,offincluded — the stamp is data, not a decision, and it is what makes a later switch toenforcemeaningful.- An event whose
instance_page_idis set is exactly as visible as that instance (the same per-instance read ACLexpand/list_instancesapply). An un-triaged event is visible only to the subject that captured it. Admin bypasses. An event captured before the stamp existed carries none and stays ungated, the same compat fallback the chat ACL takes for an unresolvable owner. capture_eventis idempotent onevent_idand returns the STORED first-writer row, which makes a guessed id a read. When the caller may not see that row it gets its OWN submission back instead, wearing the storedevent_id— indistinguishable from a first capture, disclosing neither the stored content nor that the id was taken. The rightful owner's retry still reads back the authoritative stored event, so retry convergence is unchanged. The capture webhook always carries the stored event, never the echo.list_inbox/list_eventsdrop the events the caller may not see, post-query, so a page may be shorter thanlimit.assign_eventrefuses a claim of an event the caller may not see as not found, byte-identical to a claim of an event that does not exist — an error that distinguished them would be an existence oracle. The compare-and-set is unchanged behind it.
The knob is separate from ESCUREL_WRITE_ACL on purpose. The inbox is a
work queue: a drain loop under a non-admin agent token legitimately
reads events it did not capture, and enabling this would blind it. Run
log first to find those callers (they are warned, not denied), then
enforce. On a gateway with no verifier wired every caller is admin and
this gate is inert, exactly like the instance ACL.
Capture webhook (opt-in). When ESCUREL_WEBHOOK_URL is set, each
capture_event fires a fire-and-forget HTTP POST of the stored
event's JSON to that URL — the notification an external processing agent
subscribes to. Delivery never blocks or fails the capture (a down sink is
logged and dropped); the agent may also poll list_inbox, so a missed
POST self-heals. The fold event→state remains the external agent's job
(via assign_event + update_page); the server stays automation-free.
The delivered payload always carries an additional tenant_id field — the
gateway's authoritative tenant (single-tenant per indexer) — so the
receiver knows which tenant the event belongs to without a side channel.
When ESCUREL_WEBHOOK_SECRET is also set, the gateway authenticates the
POST: it serializes the body once, computes HMAC-SHA256 over those exact
body bytes under the secret, and sends it as the header
X-Escurel-Webhook-Signature: sha256=<hex> (lowercase hex of the 32-byte
digest), POSTing the same bytes with content-type: application/json. The
receiver recomputes the HMAC over the raw request body and rejects a
missing/mismatched signature (a constant-time compare). With no secret
configured the POST is unsigned (dev). This is the only ingress trust
anchor between the gateway and the external runner.
By default an instance's data is native markdown (writable, block-grain, CRDT-backed). External backends let an instance's data live elsewhere while every escurel invariant holds — single referent space, markdown-canonical, derivable index, fail-closed ACL, single-writer:
sql_view— a read-only DuckDBVIEWover an external relational source (postgres / mysql / sqlite / erpl / json_dir / parquet_dir).document— an uploaded file (PDF / DOCX / PPTX / XLSX, or text) extracted, chunked, and embedded into one page-with-blocks.openapi/mcp— live remote (proxy) instances: the body/data is fetched live onexpandfrom a REST/OpenAPI endpoint (openapi) or an upstream MCP server (mcp), with optional write-back. Nothing is materialised in DuckDB. See Remote backends.
The unifying idea: every external instance keeps a markdown overlay page —
the page is the instance in the referent space (identity, links, ACL, history
all reuse the existing machinery), and a backend_ref frontmatter block binds
it to the external data. All novelty is confined to where the body/data comes
from, so resolve / expand / neighbours / list_* / search route
through the backend transparently and no dispatcher or wire change is needed to
add one. A skill selects its backend in frontmatter:
backend:
kind: sql_view # markdown (default) | sql_view | document | openapi | mcp
# …kind-specific config (see below)…list_skills reports each skill's backend.kind + a capabilities object
(writable, granularity, search, supports_crdt); sql_view,
document, openapi, and mcp are all writable: false, so update_page /
apply_op against them return backend_read_only (the overlay/source is not
editable through the page API — remote backends accept write-back only through
the explicit write_instance tool). Remote backends additionally report
search: "none" — their live data is never indexed, so it feeds no search
lane (the overlay page itself is still indexed and searchable like any page).
Skill frontmatter declares the source, a projection, and the columns that feed search:
backend:
kind: sql_view
source: { connector: postgres, attach: crm_pg, relation: public.customers,
filter: "region = 'EU'" } # filter is optional, injection-guarded
project: { customer_id: id, display_name: name } # source col → overlay field
search_text: [name, notes] # columns that enter late FTS
projection_limit: 50 # optional; rows expand renders (default 50)- Secrets never live in markdown.
source.attachnames a credential registered out-of-band viaregister_credential(admin), realised as a DuckDBCREATE SECRET.list_credentialsreturns names only. create_sql_instance{skill, id, overlay_body?}materialises the instance under the write lock: itATTACHes the sourceREAD_ONLY, creates a managedvw_<…>view, captures asource_schema_fingerprint, and writes the overlay page withbackend_ref { kind: "sql_view", view, binding_hash, source_schema_fingerprint }.- Reads (
expand) merge the overlay (which wins) with a bounded projection of the view (expand.backend_projection = {view, rows, source, truncated, issue?}); a colliding source field is exposed undersource.<field>. Never an unbounded dump. validate_bindings(admin) re-probes each view and compares the stored fingerprint; on drift the binding is markedbinding_degradedand that view's reads fail closed (anIssue, not wrong rows).- Search contributes candidates only (late-materialised FTS over
search_text); the dispatcher applies the fail-closed ACL predicate to every lane before RRF fusion (INV-ACL-FUSION), and a view whose owner can't be resolved denies non-admins. See storage.md.
Skill frontmatter declares accepted MIME types and chunking:
backend:
kind: document
accepts: [application/pdf, text/plain]
chunk: { max_chars: 800, overlap: 80 }
lead_chunks: 8 # optional; chunk lead expand returns (default 8)An accepts: entry is an exact MIME or a type wildcard (audio/*),
claiming every subtype of that type. */* is not a wildcard — an unhandled
MIME must still park rather than be swallowed. An exact claim always beats a
wildcard one, so a broad collection cannot divert an upload from a skill that
named the MIME; within a tier the first skill by id order wins.
Ingestion is event-driven, deposited-before-processed (an upload is never lost), and runs the extractor off the per-tenant write lock:
- An external client deposits a blob and notifies escurel via one of two authenticated HTTP endpoints (see below).
- The MIME is routed to a
documentskill whoseaccepts:lists it; an unmatched MIME is parked withIssue(no_handler_skill)and the inbox blob is retained. - escurel records an immutable ingest
Event(auditable; same event log ascapture_event). - A deterministic worker extracts (kreuzberg for PDF/DOCX/PPTX/XLSX — on by
default; plain-text for
text/*) → chunks → embeds → materialises one instance = one page with N chunk blocks, under a brief write lock. The blob is canonical (content-addressed, retained); chunks are derivable (rebuildre-extracts).
audio/* takes the retain-only path (CR-4): escurel does not transcribe,
so the recording materialises as status: ok with chunk_count: 0 — an
instance with ordinary identity, links, ACL and history whose value is its
retained bytes, with the transcript supplied separately by the caller as its
own content. backend_ref.extracted then carries bytes, codec and (where
the container states it exactly, i.e. WAV) duration_ms. Like every other
document instance, a recording is managed by the ingest pipeline: update_page
against it is refused with backend_read_only.
expand returns the overlay + the top-k relevant chunks (chunks_total,
chunks_truncated), never the full text. backend_ref carries
{ kind: "document", blob_id, content_type, extract_engine, chunk_count, status } (content_type is the MIME the upload declared; fetch_blob
prefers it over sniffing the bytes). Document
chunks are ordinary blocks, so search rides the same ACL-before-fusion path as
markdown.
Two authenticated HTTP routes (not MCP tools), rate-limited per tenant as Writes:
| route | body | purpose |
|---|---|---|
POST /ingest |
{ blob_id, content_type, title?, skill?, event_id? } |
ingest a blob already deposited in the tenant's blobs/inbox/ area |
POST /ingest/upload |
{ content_type, bytes_b64, title?, skill?, event_id? } |
deposit (base64) and ingest in one call |
skill explicitly targets a document-backend skill that accepts the
MIME (422 otherwise, create-ACL enforced); absent, the skill is resolved
from the MIME (REQ-DOC-06). event_id is the caller's idempotency
key, fed to capture_event's dedup: a redelivery with the same key is
acknowledged { status: "duplicate", event_id, blob_id } without a
second inbox event and without re-running the extraction worker
(escurel#382). Absent, the server mints a ULID per request.
The download twin is GET /blob/{page_id} (bearer-authed): the
retained original bytes verbatim, with the declared (or sniffed)
Content-Type and an honest Content-Length — no base64 detour and no
25 MiB cap, unlike the fetch_blob tool it shares its ACL with. An
absent page, a hidden page and a page with no retained blob are one
indistinguishable 404 (no existence oracle).
Both ingest routes sit behind the same cross-cutting gates as MCP dispatch: a
suspended tenant refuses agent tokens (403 tenant_suspended), a
ducklake reader refuses outright (503 read_only_replica — retry
against the writer), and the per-tenant Writes budget applies (429).
Both return the pipeline outcome:
{ status, event_id, blob_id, page_id?, handler_skill?, chunk_count?, issue? }
where status ∈ materialised | extraction_failed | no_handler |
duplicate. On
extraction failure the inbox blob is retained and the instance is marked
extraction_failed (the upload is never lost).
Unlike sql_view / document (materialised, read-only), the two remote
(proxy) backends keep no local copy: an instance is a live window onto a
remote object. Its identity, links, ACL, and history are the ordinary overlay
page, but its body/data is fetched live on expand, and — because these
backends declare a write op — edits are forwarded upstream via the
explicit write_instance tool (never update_page; the remote source is
canonical). openapi proxies a REST/HTTP endpoint; mcp proxies an upstream
MCP server (escurel is the MCP client, calling a tool or reading a resource).
Four invariants that these backends deliberately revise vs. the materialised external backends, and how each is kept safe:
- Read-only → write-back. Remote instances are
writable: falsew.r.t.update_page/CRDT (the overlay body is a live projection, not co-authored), but accept write-back throughwrite_instance, gated by the target instance'sacl.update. The remote op is value-bound (payload + id map), never string-spliced. - No search lane. Live data is never indexed →
capabilities.search: "none". The overlay page's own metadata/body stays indexed and searchable. - SSRF / secrets-in-markdown. A skill's
backend.endpointnames an admin-registered endpoint (base URL + auth held server-side in theexternal_endpointsregistry), never a raw URL — so tenant markdown can never make the server fetch an arbitrary host, and no secret enters the corpus. - Live-read failure. A read that times out / errors returns the overlay
page +
backend_projection.issue— never a partial or fabricated body (thebinding_degradedpolicy).
Skill frontmatter declares the endpoint, the read/write ops, and a
project map (response JSON $.a.b path or bare key → overlay field):
# openapi — read + write
backend:
kind: openapi
endpoint: crm_rest # admin-registered (URL + auth server-side)
read: { operationId: getCustomer, path: /customers/{id} } # method defaults GET
write: # omit ⇒ read-only
method: POST
path: /customers/{id}/orders/{order_id} # {order_id} from the payload
body: { sku: "{sku}", qty: "{qty}", via: "escurel" } # optional template
project: { display_name: $.name, tier: $.account_tier }# mcp — read-only resource
backend:
kind: mcp
endpoint: upstream_kb # points at the upstream server's /mcp
read: { resource: "kb://article/{id}" } # or { tool: getArticle }
project: { title: $.title }{name} placeholders in a path / resource / body template are filled from
the overlay instance id ({id}) and, on a write, the payload's scalar
fields — flattened to dotted keys, so {order_id} and {customer.tier} both
resolve. A placeholder that cannot be resolved fails the call closed
(unfilled path/body placeholders), never sending a literal {x}. For an
OpenAPI write, an optional body: template reshapes the payload: an exact
"{name}" leaf keeps its JSON type (a number stays a number, an object stays
an object), while embedded {name} interpolates as a string; omit body: to
send the payload verbatim. For an MCP write, the payload's fields are merged
into the tool-call arguments. A read/write is also refused (fail-closed) when
the skill's backend kind does not match the kind its endpoint was
registered under. expand returns the overlay merged with the live projection
under backend_projection = { source, fields, issue? }; backend_ref carries
just { kind, endpoint } — the read/write ops are re-derived from the
skill's backend: block on each call, never persisted per-instance. In a
read:/write: op only path + method (operationId is accepted but
ignored) drive the OpenAPI call.
New MCP tools:
write_instance(write) —{ ref, payload }→ forwards a write to the target remote instance's upstreamwriteop and returns the re-projected instance.refis the instance id or[[skill::id]]. Gated by the target'sacl.update(fail-closed; admin bypasses). A skill whose binding declares nowriteop is refused (backend_read_only).create_remote_instance(admin) —{ skill, id, overlay_body? }materialises the overlay page +backend_reffor anopenapi/mcpskill (the binding comes from the skill'sbackend:block, never the caller — thecreate_sql_instancepattern).
New admin endpoint-registry tools (mirror register_credential &c.):
register_endpoint { name, kind, base_url, auth, secret? },
list_endpoints {} (names/URLs only, secret never echoed),
delete_endpoint { name }, validate_endpoints {} (probe each registered
endpoint's reachability; unreachable ⇒ that skill's reads fail closed).
The admin/operator capabilities are exposed as admin-role-gated MCP
tools over POST /mcp — there is no separate admin service. Each
requires the admin role on the OIDC token (configurable; see
platform.md); a call from a token without the
required role yields JSON-RPC error code -32001. The noun-first tenant_* family and embedding_reload also accept
verb-first dispatch ALIASES (create_tenant, list_tenants,
get_tenant, update_tenant, delete_tenant, export_tenant,
import_tenant, reload_embedding) — resolved to the canonical name
before quota, metrics, gating and dispatch, and never advertised in
tools/list. Admin-scope tools are exempt from the tenant's agent
rate budget, keyed on the same scope label discovery filters by.
The gate is enforced at
dispatch (-32001), and since the scope label landed, discovery
matches it: every tools/list entry carries scope: "agent" | "admin",
and an agent-role token receives only the scope: "agent" subset —
the tools it can actually call. Admin tokens (and verifier-less dev
mode) receive the whole surface (see
MCP-over-HTTP framing).
Tenant resolution rules are different on admin tools — the tenant is
named explicitly (tenant_id) rather than taken from the token's claim
— but because a gateway is single-tenant, a tenant_id that names a
tenant other than the one this gateway serves is refused (-32002); an
empty value means "this gateway's tenant".
| tool | inputs | outputs | purpose |
|---|---|---|---|
tenant_create |
{tenant_id, display_name?} |
{spec: {tenant_id, display_name}} |
provision a new tenant (no quotas input) |
tenant_list |
{} |
{tenants: [{tenant_id, display_name}]} |
enumerate tenants |
tenant_get |
{tenant_id} |
{spec: {tenant_id, display_name}} |
fetch one |
tenant_update |
{tenant_id, display_name?, status?, quotas?, embedding_provider?} |
{spec, rebuild_required} |
partial update: rename, suspend/resume (status: active|suspended — a suspended tenant rejects non-admin calls), per-tenant quotas, and embedding_provider (zero|gemini|embeddinggemma; changing it moves the vector space → rebuild_required: true, run rebuild). Live suspend/quota apply to the served tenant; embedding takes effect on next boot/rebuild (#247) |
tenant_delete |
{tenant_id, confirm} (confirm must equal tenant_id) |
{deleted} |
hard-delete a tenant + its on-disk state |
tenant_export |
{tenant_id} |
{format_version, tarball_b64, bytes, sha256} (tarball: canonical markdown only, gzip'd; sha256 = hex of the tarball body) |
export (blocking) |
tenant_import |
{tenant_id, tarball_b64} |
{bytes_imported} |
restore markdown into an existing tenant (blocking) |
export_pack |
{tenant_id, id, version, vertical, publisher, skills, include_instances?} |
{manifest, tarball_b64, bytes} |
build a skill pack: a deterministic tar+gz of the named skills' pages (+ instances when include_instances) with an HMAC-signed manifest ({format_version, id, version, vertical, publisher, page_count, content_hash, signature}). Requires ESCUREL_PACK_SECRET (refuses unsigned, pack_secret_not_configured); fails closed on credential-shaped page content (pack_secret_detected). See ADR-0006 |
import_pack |
{tenant_id, manifest, tarball_b64, allow_vertical_mismatch?} |
{pack, version, vertical, pages_imported, layer} |
import a signed pack as the tenant's pinned, read-only base layer: signature + content_hash verify fail-closed before unpacking (pack_signature_invalid); unsafe pack ids refuse (pack_id_invalid); unsafe entry paths / malformed pages refuse (pack_malformed) with the WHOLE pack validated before the first page lands (a bad page ⇒ zero landed pages); pages land under the reserved markdown/base/<pack>/ namespace stamped layer: base@<id>@v<version>; the pin is recorded in pack_subscriptions (a canonical input, like the credential registry). A version change on a subscribed pack refuses (pack_version_pinned), a same-version re-publish with different bytes refuses (pack_content_mismatch) — upgrades are an explicit future rebase; an unrelated vertical refuses (vertical_mismatch) unless allow_vertical_mismatch; a skill id another indexed skill page already declares refuses (pack_skill_collision — explicit shadowing is a future feature). Transport-neutral: an air-gapped tarball and a live pull are the same call. See ADR-0007 |
unsubscribe_pack |
{tenant_id, pack_id} |
{pack, pages_removed} |
drop a subscription cleanly: every base page the pack landed is removed (so rebuild cannot resurrect orphaned base content), then the pin; tenant overlays survive (a shadow simply stops shadowing); a later import_pack starts from zero. Refuses unknown packs (pack_not_subscribed) |
list_packs |
{} |
{packs: [{pack_id, version, vertical, publisher, content_hash}]} |
the subscribed packs and their pins |
rebase_pack |
{tenant_id, manifest, tarball_b64, acknowledge_conflicts?, dry_run?} |
{ok, issues, pack, from_version, to_version, pages_imported, pages_removed, conflicts_acknowledged}; with dry_run: {ok, dry_run: true, issues, pack, from_version, to_version, would_import, would_remove} |
the reviewed upgrade of a subscribed pack (REQ-REBASE-01/02) — the only operation that moves a version pin. Validates like import_pack (verify before unpack, whole pack before the first write); a field the tenant's shadow overrides AND the new version changes surfaces as a rebase_conflict Issue (skill <id> · <field>, body included) and blocks until acknowledge_conflicts=true — never auto-resolved; orphaned base pages the new version no longer ships are removed; the pin moves last. dry_run=true runs the full validation + conflict scan, applies nothing, and reports the plan (ok = a real run with the same arguments would apply: no conflicts, or conflicts with acknowledge_conflicts=true; issues stay listed either way). Refuses non-subscribed packs (pack_not_subscribed) and non-upgrades (pack_rebase_not_an_upgrade) |
submit_promotion |
{tenant_id, candidate_id, vertical, skills} |
{manifest, tarball_b64, bytes, event_id} |
the L2→L3 harvest: propose a scrubbed, signed pack candidate from this node's own skills. Default-deny (REQ-PROMO-01): every id must be a tenant-authored SKILL page carrying the curator-set promotable: true marker — instances never promote, base-layer pages are the hub's; one ineligible id refuses the whole request (promotion_not_eligible). The deterministic scrubber (the export deny set) fails the submission closed on credential-shaped content (pack_secret_detected). Setting promotable: true via update_page is itself curator-gated (promotable_requires_curator for non-admin callers). Every submission emits an immutable audit event (source: "promotion", what/when/by-whom). Maker/checker: the candidate carries version: 0; a hub curator reviews and publishes deliberately. See ADR-0008 |
admin_audit |
{tenant_id} |
{markdown_not_in_duckdb: [...], indexed_but_no_markdown: [...]} |
drift detection (two-way diff) |
rebuild |
{tenant_id} |
{done, total, current_page} |
recover the index from canonical markdown (blocking) |
attach_external |
{tenant_id, source_url} |
{source_id} (derived catalog alias) |
attach an external read-only DuckDB source |
register_credential |
{name, connector, secret} |
{ok} |
register a named external-source credential (server-side; secret never echoed) — see Instance backends |
list_credentials |
{} |
{credentials: [{name, connector, created_at, created_by}]} |
enumerate registered credentials (names only) |
delete_credential |
{name} |
{ok} |
remove a credential |
create_sql_instance |
{skill, id, overlay_body?} |
{page_id, view} |
materialise a read-only sql_view instance from a sql_view skill |
validate_bindings |
{} |
{bindings: [{page_id, view, status, detail?}]} |
re-probe every sql_view; binding_degraded ⇒ that view reads fail closed |
register_endpoint / list_endpoints / delete_endpoint / validate_endpoints |
see Remote backends | — | remote-backend endpoint registry |
create_remote_instance |
{skill, id, overlay_body?} |
{page_id, kind, endpoint} |
materialise an openapi/mcp overlay instance |
admin_index_query |
{table, limit?} |
{rows, schema} |
read up to limit rows from an allow-listed index table (pages/blocks/links/crdt_ops/crdt_snapshots/chat_messages) |
admin_list_lanes |
{} |
{lanes: [{name, backend, tenants_present}]} |
enumerate configured LaneStores |
admin_lane_keys |
{lane?, prefix?, limit?} |
{keys: [{key, size_bytes}]} |
list lane keys under a prefix |
admin_lane_blob |
{lane?, key} |
{bytes_base64, content_type} |
fetch one lane blob (≤ 1 MiB) |
admin_webhook_deliveries |
{limit?} |
{configured, deliveries: [...]} |
recent outbound capture-webhook delivery outcomes |
add_group_member |
{group_id, subject} |
{ok} |
add a principal to a custom RBAC group |
remove_group_member |
{group_id, subject} |
{ok} |
remove a principal from a group |
list_group_members |
{group_id} |
{members: [{group_id, subject, added_at, added_by}]} |
list a group's members (audit) |
embedding_reload |
{} |
{model_revision} |
hot-reload the embedding model after a degraded start |
compact_lanes |
{tenant_id} |
{ops_compacted, bytes_reclaimed} |
compact CRDT op lanes (CHECKPOINT + VACUUM + PRAGMA hnsw_compact_index, blocking) |
admin_quota |
{tenant_id} |
{queries_remaining, writes_remaining, embeds_remaining, concurrent_sessions} |
inspect the per-tenant quota snapshot |
admin_delete_chat_history |
{chat_group_id?, before_ts?, author?} |
{deleted} |
destructive purge of the conversation log |
write_instance ({ref, payload} → the re-projected instance) is an
agent tool, not admin-gated — it is authorised by the target
instance's acl.update; see Remote backends.
There is no health MCP tool. Liveness/version are the plain HTTP
endpoints GET /healthz + GET /readyz + GET /version (see the
Transport summary).
The admin_audit and rebuild tools are the operational
recovery path. The cost is ~32 ms/page; a 1000-page tenant
rebuilds in ~32 s.
rebuild, compact_lanes, tenant_export, and tenant_import
take a while. They are ordinary blocking tools/call requests over
POST /mcp: the call holds the connection open until the operation
finishes and returns the final result in the JSON-RPC result (shapes
in the table above). There is no streaming or SSE. SSE/streaming for
progress is a possible future enhancement, not current behaviour.
tenant_export is the only backup hook exposed by
escurel-server. The server never writes to a backup bucket
itself; external backup orchestrators (e.g. the substrate's
tenant-export shipper named in
../deploy/substrate.md §4)
call this endpoint on a schedule and ship the bytes to a
durable target.
Contract relied on by orchestrators:
- Read-only. Does not hold the tenant write lock; concurrent exports are allowed and foreground writes proceed normally during export.
- Read-snapshots. Exports may include a small lag (one write transaction worst case) relative to the latest committed write.
- Deterministic tarball. Canonical markdown only — the
gzip'd tar contains the tenant's
markdown/tree and nothing else (the derivable DuckDB index,cache/, andspool/are excluded;rebuildreconstructs the index from this markdown). Seestorage.md. - Blocking result. The call returns
{format_version, tarball_b64, bytes, sha256}once the whole export is assembled:format_versionis the export-format version (int) andsha256is the hex digest of the tarball body, so consumers verify before treating the tarball as durable. - Failures. Surface as a JSON-RPC
errorobject whosedatacarriesretryable: bool(see §Errordata). Retryable errors invite the consumer to re-issue the call; non-retryable errors indicate corruption and require operator intervention. - Idempotency is the consumer's responsibility. Re-running
an export produces a new tarball with potentially different
bytes (if writes happened in between); consumers key
snapshots by
{tenant_id, started_at}, not by content hash.
Standard JSON-RPC 2.0 envelope wrapping each tool call. Tool-name mapping is:
search → method = "tools/call", name = "search"
resolve → method = "tools/call", name = "resolve"
...
update_page → method = "tools/call", name = "update_page"
Tool discovery is the usual MCP tools/list response; every entry
carries its JSON Schema input definition plus two additive labels:
execution: "deterministic" | "orchestration" and
scope: "agent" | "admin". tools/list is role-scoped: an
agent-role token receives only the scope: "agent" subset (28 tools it
can actually call); an admin token — and verifier-less dev mode —
receives the whole surface. The admin role is still enforced at
tools/call dispatch (-32001); the scope label is ratcheted against
the dispatch arms by tool_registry_conformance, so the two cannot
drift. The full (unfiltered) payload is also published as an OpenAPI
3.1 document at GET /openapi.json for non-MCP HTTP clients.
Long-running tools (rebuild, compact_lanes, tenant_export,
tenant_import) block until done and return their final result in
the JSON-RPC result. There is no SSE; live op streams use the
WebSocket transport (/ws).
JSON-RPC refusals carry an additive
error.data: { code, retryable, … } object. error.code /
error.message are the frozen wire contract (never branch on message
wording); data.code is the stable string a client branches on, and
data.retryable tells it whether re-issuing the same call can ever
succeed. Codes in use:
| data.code | numeric | retryable | meaning |
|---|---|---|---|
admin_required |
-32001 |
no | admin-gated tool, agent token |
failed_precondition |
-32002 |
no | e.g. foreign tenant_id on a single-tenant gateway |
forbidden |
-32003 |
no | authenticated but not permitted (ACL) |
tenant_suspended |
-32003 |
no | suspend gate; admin can resume |
read_only_replica |
-32004 |
yes | retry the same call against the writer |
unsupported_on_replica |
-32005 |
no | surface not wired on this replica |
publish_unavailable |
-32006 |
no | not a ducklake writer |
quota_exhausted |
-32000 |
yes | plus dimension, retry_after_ms |
layer_read_only |
-32000 |
no | pack-managed base page |
session_cap_reached |
-32000 |
yes | concurrent-sessions cap; retry later |
unknown_session |
-32603 |
no | session never opened / already closed — reopen |
event_not_found |
-32602 |
no | absent OR hidden by the event ACL (no existence oracle) |
already_assigned |
-32602 |
no | another worker won the assign_event CAS |
Errors without data are unclassified internal faults; treat as
non-retryable server errors. New codes are additive.
Single endpoint, /ws. Connection auth in the upgrade request
(Authorization: Bearer ...). The upgrade applies the same tenant
suspend gate as POST /mcp (#247): a suspended tenant refuses
non-admin bearers with HTTP 403 (tenant_suspended) before the
socket opens; an admin still connects (to resume). Once connected,
the client sends a hello frame:
{ "type": "hello", "session": "sess_xyz" } // attaches to an open CRDT session
// or
{ "type": "hello", "presence_only": true } // presence + search subscriptions onlyMessage types:
type |
direction | payload |
|---|---|---|
op |
C→S | { session, op: <Loro op> } |
op_ack |
S→C | { session, merged_version, content, conflicts, issues } — to the originator |
peer_op |
S→C | { session, merged_version, content, op } — to the other attached peers |
presence |
bidi | { session, user, anchor } (heartbeat every 10 s) |
resync_required |
S→C | { session, skipped, message } — this peer fell behind |
search_subscribe |
C→S | { subscription_id, q, k?, filter? } — live search: runs the real ACL-fused search now (the initial search_event is the ack) and re-runs it on every index mutation, pushing updated hits; a missing/empty q answers {type:"error", code:"invalid_subscription"}; presence-only connections |
search_event |
S→C | { subscription_id, hits: [...] } — the current results for the subscribed query, never a delta. A reconnecting client therefore needs no replay: it re-sends search_subscribe and the initial frame already carries whatever changed while it was away (asserted by a_resubscribe_carries_what_changed_while_the_client_was_gone). Subscriptions do not survive the socket, and are ACL-fused on every re-run, not only at subscribe time (a_subscription_is_not_a_side_channel_around_the_acl) |
event_subscribe |
C→S | { subscription_id, since_event_id? } — push freshly captured bus events (#333); presence-only connections |
event_subscribe_ack |
S→C | { subscription_id } — subscription is live |
event |
S→C | { subscription_id, event: <Event>, replayed? } — one captured event this caller may read (ESCUREL_EVENT_ACL filtered, same rule as list_inbox); replayed: true marks catch-up frames |
event_lagged |
S→C | { subscription_id, skipped, message } — push stream has gaps; poll list_inbox to catch up |
close |
C→S | { session, commit: bool } |
error |
S→C | { code, message } |
The hello frame chooses one of two modes irrevocably for the
socket's lifetime:
- session mode (
{hello, session}) — the live-CRDT surface:op/op_ack/peer_op/presence(server-rebuilt, broadcast) /resync_required/close.event_subscribeis NOT available here and answersunknown_frame. - presence-only mode (
{hello, presence_only: true}) — the subscription surface:event_subscribe(+ ack/event/event_lagged),search_subscribe, andpresence(echoed to the sender only, not broadcast).
A client that wants live co-editing AND event push holds two sockets.
A reconnecting subscriber passes the last event id it processed as
since_event_id: after the ack, the still-inbox events captured
after that id are replayed oldest-first, marked replayed: true,
before the live stream. This is a best-effort, inbox-only resume,
not a gap-free one: the replay reads list_inbox, which is a queue,
not an event log — an event that was assigned or processed while the
subscriber was disconnected has left the inbox and is not
replayed. A consumer that must not miss terminal transitions
reconciles those via list_events on the instances it cares about.
The server subscribes to the live bus before the replay query, so an
event landing in between may arrive twice; dedupe by event_id
(duplicates are recoverable). Ordering rides the event-id sort — exact
for server-minted ULIDs; a caller-supplied id scheme must be monotonic
to resume on. The replay window is the most recent 10 000 inbox rows;
further behind than that, rebuild via list_inbox pagination.
Auth happens once, at the upgrade. The server does NOT re-verify the
bearer mid-connection: a socket outlives its token's exp, and an ACL
revocation bites at the next attach (documented above), not
immediately. Deployments that need a hard bound put an idle/lifetime
limit on the proxy in front (kamal-proxy), or the consumer reconnects
periodically — since_event_id replays what is still in the inbox
across the reconnect (best-effort; see Resume above).
A session fans out. An op applied by one attached client is delivered to
every other client attached to the same session as a peer_op, and a
presence frame reaches the other peers (this is what makes live cursors
possible). The originator receives op_ack and not peer_op — it has
already been told its write landed, and applying its own edit twice is the
bug a naive echo introduces.
peer_op carries the merged content as well as the raw op, so a peer can
either apply the op to its local replica or render the merged text directly.
Attaching is a read, and is ACL-gated. A principal who may not read the
page — the same may_read_instance decision expand applies — is refused
the attach with { "type": "error", "code": "forbidden" }. Without this a
session would be a side channel around the instance ACL, since a session's
live content is exactly the material being edited.
Two properties a client must design for:
- ACL is evaluated at attach, not per frame. Evaluating per frame would put a database round trip in front of every keystroke. The consequence is that an ACL revoked mid-session takes effect when that peer next attaches, not immediately. Disconnect a peer explicitly if you need revocation to bite at once.
- Presence fields are server-rebuilt. The server emits the
sessionit knows the sender is attached to and copies through onlycursor,anchoranduser. A peer cannot make another peer see a frame that claims a different session, and unrecognised keys are dropped rather than relayed. - There is no replay of missed frames. A peer that disconnects, or that
falls far enough behind the per-session broadcast buffer to receive
resync_required, must re-read the page (expand) and re-attach. The session's CRDT state is authoritative and lossless — what is not retained is the delivery history, so reconciliation is a re-read rather than a catch-up stream.
Live search subscriptions are the WS-only feature where the server pushes new hits as new pages are indexed (useful for agents watching for new events in a stream). Not v1; placeholder in the schema, off behind a feature flag.
The MCP initialize handshake echoes the client's requested
protocolVersion when present (default 2025-06-18) and reports
serverInfo = { name: "escurel", version }, where version is the
escurel-server crate semver (CARGO_PKG_VERSION) — not a bare 1.
There is no separate WebSocket protocol-version string
(escurel-ws/1 is not implemented — the /ws upgrade performs no
version negotiation).
The MCP tool JSON Schemas are served via the tools/list handshake so
client implementations can pin to a version.