Reads, writes, and capabilities
On this page
A capability is a tag on a tool that says what kind of effect the tool can have on the store. cairn stamps every tool with one of three: :cairn/read, :cairn/write, or :cairn/observe. The boundary between reading and writing is drawn by those tags, in the tool's :metadata, not by which tools a client happens to expose. A host that grants a session :cairn/read but withholds :cairn/write gets a session that can query, inspect, and orient, and cannot change a single edge — no matter what it calls.
This matters because cairn is one durable graph that many sessions touch at once, and an agent that can plan freely should not be able to overwrite another session's plan by accident. The split is coarse on purpose: three capabilities, not fourteen, so a host reasons about a session by the class it granted rather than by the tool list it exposed.
Three capabilities, one per effect class
Every tool declares its capability in its registration. The classes partition the fourteen tools cleanly.
:cairn/observe covers exactly one tool: observe. An observation is the cheapest write cairn has — it appends one freeform note and moves nothing else — so it gets its own capability. A host can hand a session the right to record observations without the right to restructure the graph.
:cairn/write covers the eight tools that change graph structure or state: task_create, task_fork, task_link, task_sever, task_set_metadata, task_update_status, handoff, and task_query_write. These create tasks, draw and cut edges, move status, set metadata, scaffold handoffs, and run the mutating query forms. Each appends an event through the same durable boundary; see Events, projection, and reconcile.
:cairn/read covers the five tools that compute over the store and return text without touching it: task_search, task_get, timeline, task_query, and task_bootstrap. A read tool resolves a target, hydrates state, renders it, and returns. task_bootstrap switches the current-task pointer as a side effect of orienting, but it records no event and writes nothing to the graph — the pointer is per-session, not part of the durable task graph, so moving it is a read-class act.
The full per-tool capability column lives in the tools reference.
The query language carries the gate as one dynamic variable
The two query tools are the interesting case, because both run the same language — TQ — over the same store, and the only difference between them is whether writes are allowed. cairn does not give the read tool a smaller language and the write tool a bigger one. It gives them the identical interpreter and flips one dynamic variable.
task_query runs TQ with mutation refused. task_query_write runs TQ with mutation allowed. When the gate is closed, any form whose head ends in ! — a mutating step like (:set-status! "completed") or a write source like (define! ...) — is refused before it can record anything. The refusal is a structured error, not a crash:
Query error: Mutations need the task_query_write surface; task_query is read-only.
One variable being the entire gate has a useful consequence: the read surface is provably non-mutating. You do not audit fourteen runners to trust that task_query cannot write; you audit one branch. Two guards back that branch. The append is gated by the flag the read tool binds shut, so a mutating step is refused before it reaches the durable boundary. And the read tool withholds the write context entirely — it binds the event sink to nothing — so even a step that somehow slipped the flag would have no boundary to append through. The check fires on a step's internal :mutation kind tag, the one (-> (schema) (:select :kind)) projects as kind=mutation, not on registry membership, so a new mutating step is gated the moment it is tagged, with no second list to keep in sync.
Set-operation operands stay read-only on both surfaces
There is a subtler rule inside task_query_write. The set-algebra steps — (:union Q), (:intersect Q), (:minus Q), (:or-else Q) — and the operand of (define! "name" Q) evaluate their sub-query Q with the write gate shut, even when the outer query runs on the write surface. You combine and define over selections; you do not mutate inside them. So any !-form inside an operand is refused — it is an error, not a silently skipped step.
This is a legal write-surface query: the operand (dormant) is read-only, and the outer (:set-status! "completed") runs against everything (active) keeps after the minus:
(-> (active) (:minus (dormant)) (:set-status! "completed"))
The mutation step returns the set it touched — each node now carrying the written status — so the call renders that set, and a trailing (:count) would report how many tasks it moved:
2 tasks:
- 2026-06-20-wire-the-cache (completed)
- 2026-06-21-link-check-gate (completed)
Now move the mutation into the operand. The operand is evaluated with the gate bound shut, so the (:set-status! "open") inside (:minus ...) hits the same refusal as a write on the read surface:
(-> (active) (:minus (-> (dormant) (:set-status! "open"))) (:set-status! "completed"))
Because the refusal is a structured cairn-query-error and the only handler is at the top of the runner, the whole call fails: it returns isError:true, the outer (:set-status! "completed") never executes, and nothing is touched. There is no per-step recovery — one refused operand mutation aborts the entire query. "Operands are read-only" means a write inside an operand aborts the call, not that the inner write is dropped while the outer write proceeds. A query that builds a target set by composing other queries can never have one of those building-block queries quietly change the data it selects from, and it can never half-apply either.
A capability is not a promise that the tool was exposed
The capability tag describes what a tool would do if called; it does not by itself decide whether a client can see the tool. Exposure is the host's job. A kli host reads the :capabilities metadata and decides, per session, which tools to register and which to deny. Each read tool, for instance, carries the same shape the host inspects:
:metadata '(:capabilities (:cairn/read))
The host never executes the tool to learn its class; it reads that tag and routes on it. Over plain MCP — kli mcp-serve cairn — the same metadata travels with each tool so any MCP client can reason about it the same way.
This is why the boundary is enforced by capability and not by tool-name exposure. Hiding task_query_write from a client's tool list is a presentation choice; refusing !-forms at the interpreter is the enforcement. The first can be reconfigured per host; the second holds wherever TQ runs. The second is the load-bearing one: an agent holding only :cairn/read that sends a mutation through task_query meets the refusal above, because the gate lives at the interpreter rather than in whatever tool list the host chose to expose. The write surface is task_query_write, reachable only when the host has granted it.
When the split is more than you need
For a single agent on a one-off task, the read/write/observe distinction is invisible — you hold all three and never think about which class a tool falls in. The capability model earns its keep when more than one session shares a store, or when a host wants to grant a constrained session (a reviewer, a watcher, a search-only helper) the ability to look without the ability to touch. If that is not your situation, treat this page as background: the tools behave the same whether or not you ever think about the tag they carry.
Related
- The tools reference — every tool with its capability gate in the parameter header
- The TQ language — the read-versus-write gate inside the query grammar, step by step
- Events, projection, and reconcile — the durable boundary every write passes through
- The current task pointer — why moving it is a read-class act
- Serve cairn over MCP — how capability metadata travels over MCP