# Reads, writes, and capabilities


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/concepts/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](/cairn/concepts/the-current-task-pointer), 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](/cairn/reference/tools).

## 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:

```text
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:

```lisp
(-> (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:

```text
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:

```lisp
(-> (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:

```lisp
: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`](/cairn/cli/mcp-serve) — 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](/cairn/reference/tools) — every tool with its capability gate in the parameter header
- [The TQ language](/cairn/reference/tq-language) — the read-versus-write gate inside the query grammar, step by step
- [Events, projection, and reconcile](/cairn/concepts/events-projection-and-reconcile) — the durable boundary every write passes through
- [The current task pointer](/cairn/concepts/the-current-task-pointer) — why moving it is a read-class act
- [Serve cairn over MCP](/cairn/cli/mcp-serve) — how capability metadata travels over MCP
