DEV Community

Christ-loisele Atidegla
Christ-loisele Atidegla

Posted on

An AI agent audit trail that is not a second copy of your database

After anything goes wrong with an AI agent, the first question is always the same: what did it actually read?

You cannot answer that later if nothing was written down at the time. So you add logging, and the obvious implementation is to log what the tool returned.

That is where it goes wrong.

The log becomes the softer target

An audit trail that stores the rows holds, after a month of operation, a copy of every record any agent has ever read. Reconstructed from tool responses, sitting in a table or a log aggregator, and nobody has written a policy for it.

Your tickets table has a policy. Every read goes through it, per record. The audit log has whoever has log access, which is usually a much longer list including anyone who can search your observability platform.

You built a careful exposure layer and then created an unguarded mirror of everything that passed through it.

Identifiers and field names, never values

The design I settled on records what was reached, not what it contained:

{
  "at": "2026-09-09T14:02:11+00:00",
  "resource": "ticket",
  "operation": "get",
  "outcome": "ok",
  "actor": "41",
  "arguments": { "id": 7 },
  "ids": [7],
  "fields": ["id", "subject", "status", "user_id"],
  "denied": 0,
  "truncated": false
}
Enter fullscreen mode Exit fullscreen mode

From that you can answer the question completely. Which records were exposed, to whom, through which tool, when, and which fields of them. To see the values you go back and read those ids from the source, where the policy still applies and the read is itself audited.

fields is the piece people leave out. Without it you know record 7 was read but not whether that included internal_notes, and the field list changes when the attribute changes.

There is one deliberate exception. arguments records what the agent supplied, including a search term, because "what was it looking for" is half of any incident. That is the agent's input, and it should be read back as untrusted text since a prompt can put anything there.

The distinction the agent must not see

get returns null for both a missing record and a denied one, so ids cannot be enumerated.

The operator needs to know which it was. A run of denials is somebody probing; a run of misses is a broken integration. Those need different responses, and the difference is invisible if both are logged the same way.

So the trail records them apart while the agent still cannot tell:

if ($model === null) {
    $this->record($user, 'get', AuditEvent::MISSING, ['id' => $id]);
    return null;
}

if (! $this->gate->allows($user, $model)) {
    $this->record($user, 'get', AuditEvent::DENIED, ['id' => $id], [], 1);
    return null;
}
Enter fullscreen mode Exit fullscreen mode

Identical to the caller, different in the record the caller never sees. That asymmetry is the point of having an audit log at all, and it was not expressible before, because the old code could not tell the two cases apart internally either.

Refused calls are recorded too. One filter on an unexposed field is a typo. A run of them is probing, and that pattern only exists if failures are written down.

Failing closed, including here

The uncomfortable decision: by default a sink that refuses takes the read down with it.

AuditFailedException: The audit sink refused to record ticket.get, so the
result was withheld rather than served unrecorded.
Enter fullscreen mode Exit fullscreen mode

This is the least popular thing in the package and I will make the argument for it. A trail with silent gaps cannot answer the question it exists for, and the moment a gap opens is exactly the moment it is worth the most to whoever caused it. Serving data you cannot account for is the failure the whole package exists to prevent, so the audit trail should not be the one component that degrades quietly.

It is configurable, because availability is a legitimate thing to want more:

'audit' => ['strict' => false],
Enter fullscreen mode Exit fullscreen mode

That is a real trade and it is yours to make. What matters is that it is a decision and not an accident.

Where it goes

The default writes to a Laravel log channel, so it works on a fresh install with no migration and no table to forget. That is a starting point. Log files rotate, and "what did the agent read three months ago" is eventually a query.

So the sink is an interface. Bind your own and the package uses it:

$this->app->bind(AuditSink::class, YourDatabaseSink::class);
Enter fullscreen mode Exit fullscreen mode

There is also an ArraySink shipped for your own test suite, because the useful thing to assert in an application test is that a tool call produced the record you expected.

The one line version

Log which records were exposed and to whom, and not what was in them. The source of truth already has a policy; your audit log does not.

catidegla/laravel-agent-kit, and there is a test asserting that no field value ever reaches an audit record.

Top comments (0)