> Complete content index: https://sirannon.sondelali.com/llms.txt

# Auditing a fleet

> Write one record of everything a fleet of AI agents does, from a single hook on the registry, so that an agent can answer a question put months later and name what a rule refused.

URL: https://sirannon.sondelali.com/docs/agent-audit
Section: AI agents
Version: 0.3 (@delali/sirannon-db@0.3.0, latest)

Say that months later somebody asks a lettings agency which of its AI agents served a notice to quit, and no part of the fleet can answer. Sirannon reports every committed change to its subscribers and then deletes it from the change table once it is older than `cdcRetention`. The default of one hour therefore leaves that table holding only the changes of the last hour.

A question put that late has an answer only where you keep a record of your own. Register `onAfterQuery` on the registry to write that record, because a single registration covers every database the registry opens. Each call includes the `databaseId` the agent's own database is registered under.

The agency gives every agent a database of its own, as [a database per AI agent](/docs/agent-databases) sets out. A compliance agent then answers the questions people put to the agency by reading one journal database, in place of opening every agent's file. Each code block below holds one whole file.

## Ask an agent what the fleet did

`tenancy-agent` and `arrears-agent` each record what they have done through the `recordAction` tool, which writes one row into that agent's own file. `askCompliance` puts a person's question to a third agent, whose only tool reads the journal.

Nothing writes to the journal yet, and that third agent therefore answers from an empty table.

`fleetModel` and `complianceModel` are mock models, which is what fixes the text in the panels below. Point either of them at a model string once you want the model to choose.

<CodeGroup defaultTab="journal.ts">

```ts title="journal.ts"

const driver = betterSqlite3()
const sirannon = new Sirannon({ driver })

rmSync('./data', { recursive: true, force: true })
mkdirSync('./data', { recursive: true })

const journal = await sirannon.open('journal', './data/journal.db')

await journal.execute(`CREATE TABLE journal_entry (
  id INTEGER PRIMARY KEY,
  agent TEXT NOT NULL,
  values_json TEXT NOT NULL,
  outcome TEXT NOT NULL
)`)

const openAgent = async (agentId: string): Promise<Database> => {
  const open = sirannon.get(agentId)
  if (open !== undefined) return open
  const db = await sirannon.open(agentId, `./data/${agentId}.db`)
  await db.execute(`CREATE TABLE action (
    id INTEGER PRIMARY KEY,
    kind TEXT NOT NULL,
    tenancy TEXT NOT NULL,
    taken_on TEXT NOT NULL
  )`)
  return db
}

const deskTools = (db: Database) => ({
  recordAction: tool({
    description: 'Record an action this agent has taken on a tenancy.',
    inputSchema: z.object({ kind: z.string(), tenancy: z.string(), takenOn: z.string() }),
    execute: async ({ kind, tenancy, takenOn }: TenancyAction): Promise<RecordOutcome> => {
      await db.execute('INSERT INTO action (kind, tenancy, taken_on) VALUES (?, ?, ?)', [kind, tenancy, takenOn])
      return { outcome: 'recorded', reason: null }
    },
  }),
})

const act = async (agentId: string, action: TenancyAction): Promise<string> => {
  const agent = new ToolLoopAgent({
    model: fleetModel(action),
    instructions: 'Record what you have done on this tenancy, then say what you recorded.',
    tools: deskTools(await openAgent(agentId)),
  })
  const reply = await agent.generate({ prompt: `Record a ${action.kind} at ${action.tenancy} on ${action.takenOn}.` })
  return `${agentId} says: ${reply.text}`
}

const complianceTools = {
  searchJournal: tool({
    description: 'Find every journal entry naming a tenancy.',
    inputSchema: z.object({ tenancy: z.string() }),
    execute: async ({ tenancy }: { tenancy: string }): Promise<JournalEntry[]> => {
      const rows = await journal.query<{ agent: string; values_json: string; outcome: string }>(
        'SELECT agent, values_json, outcome FROM journal_entry WHERE values_json LIKE ? ORDER BY id',
        [`%${tenancy}%`]
      )
      return rows.map((row) => ({
        agent: row.agent,
        values: JSON.parse(row.values_json) as string[],
        outcome: row.outcome,
      }))
    },
  }),
}

const askCompliance = async (tenancy: string): Promise<string> => {
  const agent = new ToolLoopAgent({
    model: complianceModel(tenancy),
    instructions: 'Answer from the journal, using the searchJournal tool.',
    tools: complianceTools,
  })
  const reply = await agent.generate({ prompt: `What did our agents do at ${tenancy}?` })
  return reply.text
}

const fleetSaid = [
  await act('tenancy-agent', { kind: 'notice to quit', tenancy: '14 Rua do Sol', takenOn: '2026-02-03' }),
  await act('arrears-agent', { kind: 'payment plan', tenancy: '9 Ulica Miodowa', takenOn: '2026-02-11' }),
]

console.log(JSON.stringify({ fleetSaid, askedAboutRuaDoSol: await askCompliance('14 Rua do Sol') }, null, 2))

await sirannon.shutdown()
```

```json result open
{
  "fleetSaid": [
    "tenancy-agent says: I have recorded a notice to quit at 14 Rua do Sol, dated 2026-02-03.",
    "arrears-agent says: I have recorded a payment plan at 9 Ulica Miodowa, dated 2026-02-11."
  ],
  "askedAboutRuaDoSol": "I hold no record of anything done at 14 Rua do Sol."
}
```

```ts title="mock-model.ts"

const NO_USAGE = {
  inputTokens: { total: 0, noCache: 0, cacheRead: undefined, cacheWrite: undefined },
  outputTokens: { total: 0, text: 0, reasoning: undefined },
}

export interface TenancyAction {
  kind: string
  tenancy: string
  takenOn: string
}

export interface RecordOutcome {
  outcome: string
  reason: string | null
}

export interface JournalEntry {
  agent: string
  values: string[]
  outcome: string
}

interface ToolResultPart {
  toolName?: string
  output?: { value?: unknown }
}

interface PromptMessage {
  role: string
  content: ToolResultPart[] | string
}

const answered = (prompt: unknown, toolName: string): unknown => {
  for (const message of prompt as PromptMessage[]) {
    if (message.role !== 'tool' || typeof message.content === 'string') continue
    for (const part of message.content) {
      if (part.toolName === toolName) return part.output?.value
    }
  }
  return undefined
}

const callTool = (toolName: string, input: Record<string, unknown>) => ({
  content: [{ type: 'tool-call' as const, toolCallId: `${toolName}-1`, toolName, input: JSON.stringify(input) }],
  finishReason: { unified: 'tool-calls' as const, raw: undefined },
  usage: NO_USAGE,
  warnings: [],
})

const say = (text: string) => ({
  content: [{ type: 'text' as const, text }],
  finishReason: { unified: 'stop' as const, raw: undefined },
  usage: NO_USAGE,
  warnings: [],
})

export const fleetModel = (action: TenancyAction) =>
  new MockLanguageModelV4({
    doGenerate: async ({ prompt }) => {
      const result = answered(prompt, 'recordAction') as RecordOutcome | undefined
      if (result === undefined) return callTool('recordAction', { ...action })
      if (result.outcome === 'recorded') {
        return say(`I have recorded a ${action.kind} at ${action.tenancy}, dated ${action.takenOn}.`)
      }
      return say(`The desk refused a ${action.kind} at ${action.tenancy}, because ${result.reason}.`)
    },
  })

export const complianceModel = (tenancy: string) =>
  new MockLanguageModelV4({
    doGenerate: async ({ prompt }) => {
      const entries = answered(prompt, 'searchJournal') as JournalEntry[] | undefined
      if (entries === undefined) return callTool('searchJournal', { tenancy })
      if (entries.length === 0) return say(`I hold no record of anything done at ${tenancy}.`)
      const lines = entries.map((entry) => {
        const [kind, , takenOn] = entry.values
        if (entry.outcome === 'applied') return `${entry.agent} recorded a ${kind} on ${takenOn}`
        return `a hook refused ${entry.agent} a ${kind} on ${takenOn}, because ${entry.outcome}`
      })
      return say(`At ${tenancy}, ${lines.join('. Then ')}.`)
    },
  })
```

</CodeGroup>

Both agents do the work and report it, while the compliance agent reads an empty journal. Every action is in the file its own agent writes, which means the compliance agent would need the name of each agent, and a query against each file, before it could find one.

## Record every write from one place

`sirannon.onAfterQuery` runs after each statement on every database in the registry, which is how the statements from `tenancy-agent`, `arrears-agent`, and any agent the registry opens later all reach the same handler. That handler skips the journal's own writes, because a journal entry is itself a statement and would otherwise record itself.

Sirannon never waits for what this hook returns. Two calls to an asynchronous handler can therefore overlap, and one journal write can still be under way when you read the table. Chaining each write onto `journalled` puts them in order. A service leaves that chain alone, while this file awaits it once, before the compliance agent reads the journal. The `catch` returns the chain to a resolved state, which lets the writes behind a failed one carry on. Send that error to your own logging system, and treat the console call here as a stand-in.

<CodeGroup defaultTab="journal.ts">

```ts title="journal.ts"

const driver = betterSqlite3()
const sirannon = new Sirannon({ driver })

rmSync('./data', { recursive: true, force: true })
mkdirSync('./data', { recursive: true })

const journal = await sirannon.open('journal', './data/journal.db')

await journal.execute(`CREATE TABLE journal_entry (
  id INTEGER PRIMARY KEY,
  agent TEXT NOT NULL,
  values_json TEXT NOT NULL,
  outcome TEXT NOT NULL
)`)
// [!code ++:16]

const WRITES = /^\s*(INSERT|UPDATE|DELETE)\b/i

let journalled: Promise<unknown> = Promise.resolve()

const record = (agent: string, values: unknown, outcome: string): void => {
  const entry = [agent, JSON.stringify(values ?? []), outcome]
  journalled = journalled
    .then(() => journal.execute('INSERT INTO journal_entry (agent, values_json, outcome) VALUES (?, ?, ?)', entry))
    .catch((error: Error) => console.error('journal write failed', error.message))
}

sirannon.onAfterQuery((ctx) => {
  if (ctx.databaseId === 'journal' || !WRITES.test(ctx.sql)) return
  record(ctx.databaseId, ctx.params, 'applied')
})

const openAgent = async (agentId: string): Promise<Database> => {
  const open = sirannon.get(agentId)
  if (open !== undefined) return open
  const db = await sirannon.open(agentId, `./data/${agentId}.db`)
  await db.execute(`CREATE TABLE action (
    id INTEGER PRIMARY KEY,
    kind TEXT NOT NULL,
    tenancy TEXT NOT NULL,
    taken_on TEXT NOT NULL
  )`)
  return db
}

const deskTools = (db: Database) => ({
  recordAction: tool({
    description: 'Record an action this agent has taken on a tenancy.',
    inputSchema: z.object({ kind: z.string(), tenancy: z.string(), takenOn: z.string() }),
    execute: async ({ kind, tenancy, takenOn }: TenancyAction): Promise<RecordOutcome> => {
      await db.execute('INSERT INTO action (kind, tenancy, taken_on) VALUES (?, ?, ?)', [kind, tenancy, takenOn])
      return { outcome: 'recorded', reason: null }
    },
  }),
})

const act = async (agentId: string, action: TenancyAction): Promise<string> => {
  const agent = new ToolLoopAgent({
    model: fleetModel(action),
    instructions: 'Record what you have done on this tenancy, then say what you recorded.',
    tools: deskTools(await openAgent(agentId)),
  })
  const reply = await agent.generate({ prompt: `Record a ${action.kind} at ${action.tenancy} on ${action.takenOn}.` })
  return `${agentId} says: ${reply.text}`
}

const complianceTools = {
  searchJournal: tool({
    description: 'Find every journal entry naming a tenancy.',
    inputSchema: z.object({ tenancy: z.string() }),
    execute: async ({ tenancy }: { tenancy: string }): Promise<JournalEntry[]> => {
      const rows = await journal.query<{ agent: string; values_json: string; outcome: string }>(
        'SELECT agent, values_json, outcome FROM journal_entry WHERE values_json LIKE ? ORDER BY id',
        [`%${tenancy}%`]
      )
      return rows.map((row) => ({
        agent: row.agent,
        values: JSON.parse(row.values_json) as string[],
        outcome: row.outcome,
      }))
    },
  }),
}

const askCompliance = async (tenancy: string): Promise<string> => {
  const agent = new ToolLoopAgent({
    model: complianceModel(tenancy),
    instructions: 'Answer from the journal, using the searchJournal tool.',
    tools: complianceTools,
  })
  const reply = await agent.generate({ prompt: `What did our agents do at ${tenancy}?` })
  return reply.text
}

const fleetSaid = [
  await act('tenancy-agent', { kind: 'notice to quit', tenancy: '14 Rua do Sol', takenOn: '2026-02-03' }),
  await act('arrears-agent', { kind: 'payment plan', tenancy: '9 Ulica Miodowa', takenOn: '2026-02-11' }),
]

// [!code ++:2]
await journalled

console.log(JSON.stringify({ fleetSaid, askedAboutRuaDoSol: await askCompliance('14 Rua do Sol') }, null, 2))

await sirannon.shutdown()
```

```json result open
{
  "fleetSaid": [
    "tenancy-agent says: I have recorded a notice to quit at 14 Rua do Sol, dated 2026-02-03.",
    "arrears-agent says: I have recorded a payment plan at 9 Ulica Miodowa, dated 2026-02-11."
  ],
  "askedAboutRuaDoSol": "At 14 Rua do Sol, tenancy-agent recorded a notice to quit on 2026-02-03."
}
```

</CodeGroup>

The compliance agent names `tenancy-agent` and the date of the notice. The journal keeps that entry after the change feed prunes the change, and after somebody deletes the row itself.

<FleetJournalDiagram
  hook="sirannon.onAfterQuery"
  hookDetail="Register it once, before the first agent opens."
  databases={[
    { name: 'tenancy-agent.db', detail: 'The file holds every notice to quit this agent serves.' },
    { name: 'arrears-agent.db', detail: 'The file holds every payment plan this agent agrees.' },
    { name: 'every later agent', detail: 'The hook covers a file the registry opens long after this line runs.', dimmed: true },
  ]}
  journal="journal.db"
  journalDetail="It holds one entry for each write, naming the agent that ran it."
  caption="The handler reads databaseId off each statement and writes it into the entry, which is why the agent's own table needs no column for the actor."
/>

## Record what a rule refused

A before-hook refuses a statement by throwing. A refused statement reaches no table, and `onAfterQuery` never runs for it. A journal built from that hook alone therefore holds the writes the fleet completes, and a person asking whether an agent tried something would read nothing about the attempt.

Catch `HookDeniedError` where the tool makes its write, and record the attempt under the same agent. The hook here refuses a notice to quit on a tenancy under six months old. The tool returns that reason to the model, which lets the agent explain itself to the person who asked.

<CodeGroup defaultTab="journal.ts">

```ts title="journal.ts"

import { type Database, Sirannon } from '@delali/sirannon-db' // [!code --]
import { type Database, HookDeniedError, Sirannon } from '@delali/sirannon-db' // [!code ++]

const driver = betterSqlite3()
const sirannon = new Sirannon({ driver })

rmSync('./data', { recursive: true, force: true })
mkdirSync('./data', { recursive: true })

const journal = await sirannon.open('journal', './data/journal.db')

await journal.execute(`CREATE TABLE journal_entry (
  id INTEGER PRIMARY KEY,
  agent TEXT NOT NULL,
  values_json TEXT NOT NULL,
  outcome TEXT NOT NULL
)`)

const WRITES = /^\s*(INSERT|UPDATE|DELETE)\b/i
// [!code ++:2]
const UNDER_SIX_MONTHS = 'the tenancy has run for less than six months'
const RECENT_TENANCIES = new Set(['9 Ulica Miodowa'])

let journalled: Promise<unknown> = Promise.resolve()

const record = (agent: string, values: unknown, outcome: string): void => {
  const entry = [agent, JSON.stringify(values ?? []), outcome]
  journalled = journalled
    .then(() => journal.execute('INSERT INTO journal_entry (agent, values_json, outcome) VALUES (?, ?, ?)', entry))
    .catch((error: Error) => console.error('journal write failed', error.message))
}

sirannon.onAfterQuery((ctx) => {
  if (ctx.databaseId === 'journal' || !WRITES.test(ctx.sql)) return
  record(ctx.databaseId, ctx.params, 'applied')
})

// [!code ++:8]
sirannon.onBeforeQuery((ctx) => {
  if (!ctx.sql.startsWith('INSERT INTO action')) return
  const [kind, tenancy] = Array.isArray(ctx.params) ? (ctx.params as string[]) : []
  if (kind === 'notice to quit' && tenancy !== undefined && RECENT_TENANCIES.has(tenancy)) {
    throw new HookDeniedError(UNDER_SIX_MONTHS)
  }
})

const openAgent = async (agentId: string): Promise<Database> => {
  const open = sirannon.get(agentId)
  if (open !== undefined) return open
  const db = await sirannon.open(agentId, `./data/${agentId}.db`)
  await db.execute(`CREATE TABLE action (
    id INTEGER PRIMARY KEY,
    kind TEXT NOT NULL,
    tenancy TEXT NOT NULL,
    taken_on TEXT NOT NULL
  )`)
  return db
}

const deskTools = (db: Database) => ({ // [!code --]
const deskTools = (agentId: string, db: Database) => ({ // [!code ++]
  recordAction: tool({
    description: 'Record an action this agent has taken on a tenancy.',
    inputSchema: z.object({ kind: z.string(), tenancy: z.string(), takenOn: z.string() }),
    execute: async ({ kind, tenancy, takenOn }: TenancyAction): Promise<RecordOutcome> => {
// [!code --:2]
      await db.execute('INSERT INTO action (kind, tenancy, taken_on) VALUES (?, ?, ?)', [kind, tenancy, takenOn])
      return { outcome: 'recorded', reason: null }
// [!code ++:8]
      try {
        await db.execute('INSERT INTO action (kind, tenancy, taken_on) VALUES (?, ?, ?)', [kind, tenancy, takenOn])
        return { outcome: 'recorded', reason: null }
      } catch (error) {
        if (!(error instanceof HookDeniedError)) throw error
        record(agentId, [kind, tenancy, takenOn], UNDER_SIX_MONTHS)
        return { outcome: 'refused', reason: UNDER_SIX_MONTHS }
      }
    },
  }),
})

const act = async (agentId: string, action: TenancyAction): Promise<string> => {
  const agent = new ToolLoopAgent({
    model: fleetModel(action),
    instructions: 'Record what you have done on this tenancy, then say what you recorded.',
    tools: deskTools(await openAgent(agentId)), // [!code --]
    tools: deskTools(agentId, await openAgent(agentId)), // [!code ++]
  })
  const reply = await agent.generate({ prompt: `Record a ${action.kind} at ${action.tenancy} on ${action.takenOn}.` })
  return `${agentId} says: ${reply.text}`
}

const complianceTools = {
  searchJournal: tool({
    description: 'Find every journal entry naming a tenancy.',
    inputSchema: z.object({ tenancy: z.string() }),
    execute: async ({ tenancy }: { tenancy: string }): Promise<JournalEntry[]> => {
      const rows = await journal.query<{ agent: string; values_json: string; outcome: string }>(
        'SELECT agent, values_json, outcome FROM journal_entry WHERE values_json LIKE ? ORDER BY id',
        [`%${tenancy}%`]
      )
      return rows.map((row) => ({
        agent: row.agent,
        values: JSON.parse(row.values_json) as string[],
        outcome: row.outcome,
      }))
    },
  }),
}

const askCompliance = async (tenancy: string): Promise<string> => {
  const agent = new ToolLoopAgent({
    model: complianceModel(tenancy),
    instructions: 'Answer from the journal, using the searchJournal tool.',
    tools: complianceTools,
  })
  const reply = await agent.generate({ prompt: `What did our agents do at ${tenancy}?` })
  return reply.text
}

const fleetSaid = [
  await act('tenancy-agent', { kind: 'notice to quit', tenancy: '14 Rua do Sol', takenOn: '2026-02-03' }),
  await act('arrears-agent', { kind: 'payment plan', tenancy: '9 Ulica Miodowa', takenOn: '2026-02-11' }),
  await act('tenancy-agent', { kind: 'notice to quit', tenancy: '9 Ulica Miodowa', takenOn: '2026-02-18' }), // [!code ++]
]

await journalled

console.log(JSON.stringify({ fleetSaid, askedAboutRuaDoSol: await askCompliance('14 Rua do Sol') }, null, 2)) // [!code --]
// [!code ++:11]
console.log(
  JSON.stringify(
    {
      fleetSaid,
      askedAboutRuaDoSol: await askCompliance('14 Rua do Sol'),
      askedAboutUlicaMiodowa: await askCompliance('9 Ulica Miodowa'),
    },
    null,
    2
  )
)

await sirannon.shutdown()
```

```json result open
{
  "fleetSaid": [
    "tenancy-agent says: I have recorded a notice to quit at 14 Rua do Sol, dated 2026-02-03.",
    "arrears-agent says: I have recorded a payment plan at 9 Ulica Miodowa, dated 2026-02-11.",
    "tenancy-agent says: The desk refused a notice to quit at 9 Ulica Miodowa, because the tenancy has run for less than six months."
  ],
  "askedAboutRuaDoSol": "At 14 Rua do Sol, tenancy-agent recorded a notice to quit on 2026-02-03.",
  "askedAboutUlicaMiodowa": "At 9 Ulica Miodowa, arrears-agent recorded a payment plan on 2026-02-11. Then a hook refused tenancy-agent a notice to quit on 2026-02-18, because the tenancy has run for less than six months."
}
```

</CodeGroup>

The compliance agent now reports both the payment plan and the notice the six-month rule refuses, and `tenancy-agent` gives the same reason at the moment of the refusal. Both answers come from one journal, since the tool writes the refusal through the same `record` function the hook uses.

## Where to go next

The journal is a Sirannon database, which means [continuous backups](/docs/backup-chains) copy it as it grows and a restore can rebuild it, as [restoring a database](/docs/backup-restore) sets out. Where the record shows an agent writing something you never allowed, [rewinding memory](/docs/agent-memory-rewind) puts that agent's database back to a moment before the write. The [hooks reference](/docs/hooks-metrics-and-lifecycle) lists every hook the registry takes, along with the metrics callbacks for each change event and its subscriber count.
