Chapter 70 · Tools and actions
Read tools against write tools
Two different boundaries, and a system that treats them as one. What changes for permissions, testing, auditing and failure handling the moment a tool writes.
12 min read
Chapter 13 built one Scope object and insisted every path consume it. That was correct and it is not sufficient here: reading a thing and changing it are different permissions, and most companies model them separately.
A rep can read every policy document. A rep cannot change one. The same rep can read their own accounts and can raise a quote on them but not approve a credit. One classifications array does not express that.
The scope, extended
// src/retrieve/scope.ts — extended, not replaced. Chapter 13's fields are
// unchanged, so nothing in sections 4 and 5 changes.
export interface Scope {
userId: string
classifications: string[] // read: which document classes
accountIds: string[] // read: which accounts
warehouseIds: string[]
// New, and deliberately a separate axis.
capabilities: Capability[] // write: what this person may DO
limits: { creditAmount: number; discountPercent: number }
}
export type Capability =
| 'draft_quote' | 'send_quote' | 'open_ticket' | 'apply_credit' | 'update_order'// Resolved from the systems of record, as reads are. ⚠️ From the role
// system, not inferred from read access — "can see it" is not "can change it"
// and inferring one from the other is how a write boundary disappears.
const CAPABILITIES_BY_ROLE: Record<Role, Capability[]> = {
inside_sales: ['draft_quote', 'open_ticket'],
senior_sales: ['draft_quote', 'send_quote', 'open_ticket'],
warehouse_supervisor: ['open_ticket', 'update_order'],
finance: ['apply_credit', 'send_quote', 'open_ticket'],
}A tool then declares what it needs:
export const sendQuote: Tool = {
name: 'send_quote',
kind: 'write',
requires: 'send_quote',
reversible: false,
external: true, // ⚠️ leaves the company. Chapter 71.
permitted: (scope) => scope.capabilities.includes('send_quote'),
// ...
}Eight things that change when a tool writes
| Read | Write | |
|---|---|---|
| Permission | Classification and ownership | A separate capability |
| Wrong result | Misleads one person | Changes a system of record |
| Retry | Free | Must be idempotent |
| Timeout | Return unavailable | May have succeeded |
| Testing | Call it | Needs a sandbox and fixtures |
| Audit | Useful | Mandatory |
| Confirmation | Never | Sometimes |
| Parallelism | Safe | Often unsafe |
The fourth row is the one people underestimate. A read that times out returned nothing and you know it; a write that times out may have committed, and your code cannot tell.
// ❌ The retry that creates a second quote.
try {
return await api.sendQuote(payload)
} catch (e) {
if (isTimeout(e)) return await api.sendQuote(payload) // ⚠️ possibly the second
}
// ✅ A timeout on a write is an UNKNOWN state, not a failure. Reconcile
// before retrying. Chapter 73 makes this systematic.
try {
return await api.sendQuote(payload, { idempotencyKey: key })
} catch (e) {
if (!isTimeout(e)) throw e
const existing = await api.findQuoteByKey(key)
if (existing) return existing
return await api.sendQuote(payload, { idempotencyKey: key })
}Enforcing the split structurally
A capability check that each tool remembers to perform is a check that one tool will forget.
// The registry enforces it, not the tool.
export function registerTool(tool: Tool): Tool {
if (tool.kind === 'write' && !tool.requires) {
throw new Error(`write tool '${tool.name}' declares no required capability`)
}
if (tool.kind === 'write' && tool.reversible === undefined) {
throw new Error(`write tool '${tool.name}' does not declare reversibility`)
}
if (tool.kind === 'read' && tool.requires) {
throw new Error(`read tool '${tool.name}' should not require a capability`)
}
return tool
}// And the test that catches a write tool declared as a read — which is
// how the whole split gets bypassed in one careless commit.
test('write tools are declared as writes', async () => {
const sources = await readAll('src/tools/**/*.ts')
const MUTATING = /\b(insert into|update |delete from|\.post\(|\.put\(|\.patch\(|\.delete\()/i
for (const file of sources) {
const tool = toolFromFile(file)
if (MUTATING.test(file.text) && tool.kind !== 'write') {
throw new Error(`${tool.name} performs a mutation but is declared '${tool.kind}'`)
}
}
})That grep is crude and it is the check that keeps working after everyone has forgotten the convention — the same pattern as chapter 13's unscoped-query test.
Read before write, as a precondition
Chapter 65's duplicate tickets came from a write with an unenforced read precondition.
export const openTicket: Tool = {
name: 'open_ticket',
kind: 'write',
requires: 'open_ticket',
reversible: true,
// ⚠️ Declared, and checked by the executor — not a sentence in the
// description that the model follows most of the time.
preconditions: ['search_tickets'],
// ...
}// Checked in executeToolRequest, so every write tool gets it for free.
for (const required of tool.preconditions ?? []) {
if (!context.called.includes(required)) {
return { ok: false, error: 'precondition_not_met',
message: `Call ${required} first and confirm no existing record covers this.` }
}
}⚠️ A precondition is not a substitute for judgement. Calling search_tickets and ignoring the result satisfies the check, and on Atlas that happened on about 8% of ticket creations — which is why the ticket tool also returns the open tickets it found and the prompt requires the model to say why a new one is needed.
Auditing a write
Reads are logged for debugging. Writes are logged because someone will ask what happened.
// src/tools/audit.ts — written BEFORE the attempt and updated after, so a
// crash mid-write leaves a record that an attempt was made.
export async function withAudit<T>(
key: string, scope: Scope, request: ToolRequest, run: () => Promise<T>,
): Promise<T> {
const id = await db.query(
`insert into tool_audit (idempotency_key, user_id, tool, arguments, status, started_at)
values ($1, $2, $3, $4, 'attempting', now())
on conflict (idempotency_key) do nothing
returning id`,
[key, scope.userId, request.tool, redact(request.arguments)],
)
try {
const result = await run()
await db.query(
`update tool_audit set status = 'succeeded', result = $2, finished_at = now()
where id = $1`, [id, summarise(result)])
return result
} catch (error) {
// ⚠️ 'failed' is not the same as 'unknown'. A timeout leaves the row
// as 'attempting', which is exactly right and is what reconciliation
// looks for.
const status = isTimeout(error) ? 'unknown' : 'failed'
await db.query(`update tool_audit set status = $2, error = $3, finished_at = now()
where id = $1`, [id, status, String(error)])
throw error
}
}-- The query somebody will ask for, and it should take ten seconds.
select a.started_at, u.name as actor, a.tool, a.arguments, a.status, a.result
from tool_audit a join users u on u.id = a.user_id
where a.arguments->>'accountId' = '4471'
and a.started_at > now() - interval '30 days'
order by a.started_at desc;Three fields make that query answerable: who, what arguments, and what happened. Logging the tool name alone is a log line, not an audit.
Testing a write tool
// Reads are tested against the real system, read-only. Writes need a
// sandbox, and the sandbox has to be real enough to fail the same way.
export const testEnvironments = {
read: { database: 'replica', api: 'production_readonly' },
write: { database: 'sandbox', api: 'api_sandbox' },
} as const// ⚠️ The guard that prevents the obvious accident. A write tool must not
// be executable against production from a test process.
export function assertSafeToWrite(env: string) {
if (process.env.NODE_ENV === 'test' && env !== 'sandbox') {
throw new Error('refusing to execute a write tool against a non-sandbox environment')
}
}// And the fixtures a write test needs, which reads do not: a known
// starting state, and an assertion on the side effect rather than on the
// return value.
test('draft_quote creates exactly one draft', async () => {
await resetSandbox()
const before = await sandbox.countQuotes({ accountId: '4471' })
await executeToolRequest(
{ tool: 'draft_quote', arguments: { accountName: 'Henderson Industrial', sku: 'SKU-4411', quantity: 200 } },
SCOPE.insideSales, freshContext())
expect(await sandbox.countQuotes({ accountId: '4471' })).toBe(before + 1)
})Asserting on the count rather than on the return value is what catches a tool that succeeds twice — which is the idempotency failure, visible here for free.
Rolling out writes
Shadow mode is the stage worth not skipping, and it is cheap.
// Everything runs except the effect. The validation, resolution and
// confirmation decisions are all exercised and recorded.
if (config.tools.shadowMode.includes(tool.name)) {
const preview = await tool.preview(args, scope)
await recordShadow(key, scope, request, preview)
return { ok: true, shadow: true, preview }
}Atlas ran draft_quote in shadow for two weeks: 214 shadow calls, of which 9 would have been wrong — 6 wrong account before chapter 69's resolution layer landed, 3 implausible quantities. Those nine were found for the cost of a review rather than nine wrong quotes.
The failure mode: the read tool that writes
get_order_status is a read tool. It calls an endpoint that, as a side effect, marks the order as "viewed" and timestamps it — which feeds a staleness report the operations team uses.
Atlas calls it 3,000 times a month. Every order Atlas looks at is marked as recently reviewed, and the operations report stops surfacing neglected orders.
Nothing in the tool definition is wrong. The side effect is in somebody else's system, three layers down, and it was documented in a changelog from 2024.
// 1. Audit the endpoints a read tool actually touches, once, with the
// team that owns them. This is a conversation, not a code change.
// 2. Where the write cannot be removed, declare it. A read tool with a
// known side effect is a write tool with a misleading name.
export const getOrderStatus: Tool = {
name: 'get_order_status',
kind: 'write', // ⚠️ because it is
requires: 'read_orders',
reversible: true,
sideEffects: ['marks the order as viewed in the operations report'],
// ...
}
// 3. Or use an endpoint that does not. The operations team added a
// read-only variant in a day once the effect was named.A tool is a write tool if calling it changes anything anybody can observe, including in a system you do not own. "Read" is a property of the effect, not of the HTTP verb.
Key points
- Read and write are different permissions. Capabilities are a separate axis from classifications, resolved from the role system rather than inferred from read access.
- Eight things change when a tool writes; the quiet one is that a timeout is an unknown state, not a failure.
- Enforce the declaration in the registry, and grep for mutations in tools declared as reads.
- Preconditions belong in the executor, not in the description — and a satisfied precondition is not the same as a heeded one.
- Audit rows are written before the attempt and updated after, with
unknowndistinct fromfailed. - An audit needs who, what arguments, and what happened. The tool name alone is a log line.
- Test writes against a sandbox, assert on the side effect, and refuse to execute a write tool outside the sandbox in tests.
- Shadow mode found nine wrong quotes in 214 calls for the cost of a review.
- A tool is a write tool if calling it changes anything observable, including in a system you do not own.