The short answer
To audit what an AI agent changed in MySQL, turn on the binary log in row format with full row images, and read it with a change-data-capture tool such as Debezium. The general query log and the audit plugins record the statement the agent sent. Only row events show every row the database committed, including rows a trigger wrote. Even they miss InnoDB foreign key cascades on MySQL 8.4.
That last sentence is the part most guides skip, so this post leads with what each method can’t see. It ends with a recorded run on MySQL 8.4 where an agent’s one-row UPDATE committed 301 row changes, and the statement-level record of that transaction says nothing about 300 of them.
Deedglass
A sealed record you can check offline
Record 1043
- What ran
- Medication order, Bed 12
- Approved by
- Dr. N. Shah
- Check
- Matches
- Time
- 09:12
- The request
- Not keptA fingerprint only: 77d2…c41a
Each record is linked to the one before
- Record 1041
- Record 1042
- Record 1043
Five ways to audit MySQL, side by side
The question to ask of each method is not “does it log the agent?” but “would it show me rows the agent didn’t name?”
| Method | What it records | Rows a trigger wrote | Rows an InnoDB cascade wrote (8.4) | Cost and availability |
|---|---|---|---|---|
| General query log | Connects, disconnects and each statement received, in the order received1 | No: the statement only | No | Off by default; every statement from every client |
| Audit plugins and components | Connections and the actions sessions take: statements, schemas and tables touched34 | No: statement level | No | MySQL Enterprise Audit needs the commercial Enterprise Edition3; Percona Server and MariaDB ship their own45 |
Binary log, ROW format |
One row event per changed row, before and after images with binlog_row_image=full2 |
Yes14 | No: handled inside InnoDB and not logged8 | On by default in 8.415; expires after 30 days by default2 |
| Audit triggers | Whatever you write into an audit table | Only if you add triggers to every table | No: cascades don’t activate triggers6 | Your code, in every write’s transaction |
| Debezium (CDC on the binlog) | The binlog’s row events as JSON, with the GTID, file, position, thread and statement10 | Yes, from the binlog | No, for the same reason | A connector to run; needs ROW and full row images10 |
Statement logs answer “what did the agent ask for?” Row events answer “what did the database do?” When an agent is involved you want both, because the agent’s summary of what happened usually comes from the first.
Statement logs: the general log and audit plugins
MySQL’s general query log records each SQL statement it receives from clients, plus connects and disconnects. It writes them in the order received, which can differ from the order they ran, and it’s off by default.1 It’s the quickest thing to switch on during an incident. It’s also the noisiest, and it records every client, not just the agent.
Audit plugins are the production version of the same idea. MySQL Enterprise Audit logs when clients connect and disconnect and what they do while connected, such as which databases and tables they access; it’s part of MySQL Enterprise Edition, a commercial product, and writes XML by default.3 Percona Server for MySQL 8.4 has an Audit Log Filter component, which replaces its legacy audit log plugin and records who connected, which statements ran and which schemas sessions touched, filtered by rules.4 MariaDB’s audit plugin records connections, queries and table access.5
All of these are statement-level. Give the agent its own MySQL user and they tell you exactly what it sent and when, which is valuable. They don’t tell you what a statement went on to change.
The binlog in row format
The binary log is MySQL’s record of changes for replication. In MySQL 8.4, binlog_format defaults to ROW and is deprecated, because only row-based logging is meant for new setups.2 Three settings decide how useful it is as an audit trail2:
binlog_row_image=full(the default) logs every column in the before and after images.minimallogs only the key and the columns the statement set, so you lose the old values.binlog_rows_query_log_events=ON(default off) adds the original statement as an informational event beside the rows.mysqlbinlog -vvshows it.binlog_row_metadata=FULL(defaultMINIMAL) adds column names and primary key information, so a reader doesn’t need the schema to make sense of an event.
Retention matters too: binlog_expire_logs_seconds defaults to 2,592,000 seconds, 30 days.2 If your audit depends on the binlog, copy it somewhere the agent’s credentials can’t reach before it expires.
Under row-based replication, rows a trigger writes on the source are logged as row changes and applied on the replica; the trigger itself doesn’t run there.14 That is exactly what makes the binlog useful here: the trigger’s writes are in it as rows.
Debezium’s MySQL connector reads the binlog and turns each row event into a JSON change event. It requires binlog_format=ROW and binlog_row_image=FULL, and each event’s source block carries the GTID, binlog file and position, the MySQL thread id and, when binlog_rows_query_log_events is on, the statement that performed the operation.10 If the binlog is purged before the connector reads it, it can’t resume from its old position and needs a new snapshot.10
Why triggers make a poor audit trail
An AFTER INSERT/UPDATE/DELETE trigger writing to an audit table is the classic home-made answer. On MySQL it has three holes worth knowing before you rely on it:
- Cascades are invisible to it. “Cascaded foreign key actions do not activate triggers.”6 Delete a parent row and the child rows a cascade removes leave no trace in your audit table.
DROP TABLEandTRUNCATE TABLEdon’t fireDELETEtriggers.6 The most destructive things an agent can run are the ones the trigger never sees.- A trigger can’t write the table it fires on. MySQL refuses a trigger that updates the table it fires on (error 1442).7 This shapes legacy schemas too: it’s why old MySQL code often keeps derived state in a second table, written by a trigger. That second table is where surprises live.
Triggers also run inside the agent’s transaction, so a bug in the audit trigger fails the agent’s write. They’re fine as a convenience; they aren’t a record of what the database did.
A recorded run: 1 row reported, 301 committed
Here is the failure the row-level methods exist for. The Deedglass repository has a recorded Debezium feed from MySQL 8.4 with a legacy AFTER UPDATE trigger: shipping one order ships the other 300 open orders of its batch, in shop.shipments, because error 1442 rules out the same table.11 The agent’s statement was:
Code A database command in SQL. The text around it says what it does.
UPDATE orders SET status = 'shipped' WHERE id = 42;
MySQL told the client one row changed. The feed’s transaction metadata tells a different story; this is its END event, unedited:
Code Data in JSON, the format software uses to pass records and settings.
{"status":"END","id":"d37cd757-cd2b-11f1-927d-b22b7c5c4779:17","event_count":302,
"data_collections":[{"data_collection":"shop.deedglass_ledger","event_count":1},
{"data_collection":"shop.shipments","event_count":300},{"data_collection":"shop.orders","event_count":1}]}
Look at one of the 300 shipment events and you see why a statement log can’t help. Its source.query, the statement MySQL logged beside the row, is the agent’s one-row update. Every one of the 300 trigger-written events carries that same statement and the same thread id, 19.12 In a statement log, the write in this transaction is one line, and that line names one row.
We replayed the recording through deedglass witness debezium from the v0.9.0 release today. It compares what the statement should change with what the feed shows MySQL committed.11
Code What the screen or file shows, copied as is.
$ deedglass witness debezium --events mysql-trigger.jsonl.gz --schema shop \
--pk shop.orders=id --pk shop.shipments=order_id \
--sql "UPDATE orders SET status = 'shipped' WHERE id = 42" --reported 1 \
--action act_01k82m4v9q5n3d7f8g2h6j0kxp
L2 · Doesn't match · Proven · via Debezium (MySQL)
MySQL also changed 300 rows in shop.shipments, which the statement doesn't touch.
expected UPDATE 1 row in shop.orders where id = 42 · sets status
observed UPDATE 1 row in shop.orders · UPDATE 300 rows in shop.shipments
row id order_id status
✓ expected 42 open → shipped
✕ unexpected 501 open → shipped
✕ unexpected 502 open → shipped
… first 20 of 301 rows · the rest in Console › Actions
Proven · via Debezium (MySQL) · transaction d37cd757-cd2b-11f1-927d-b22b7c5c4779:17 · mysql-bin.000003:1230 · 2026-10-21 08:46:11 UTC
Read 3 transactions, 605 change events.
The command exits 1 for Doesn’t match, 0 for Matches and 2 for Can’t check.11 The action is tied to its transaction because the writer inserted a ledger row naming it in the same transaction; without one, every transaction in the feed that touches the statement’s tables is compared and the record carries the note any_writer.11
You don’t need Deedglass for the core idea. Count the row events per table in the agent’s transaction and compare them with the rows its statement named. Anything extra is the database acting on its own.
The gaps, first
- Cascades on MySQL 8.4. The manual says InnoDB carries out cascading foreign key actions internally and logs none of their changes, so a binlog-based feed doesn’t show them.8 The MySQL 9.7 manual describes a server-level foreign key implementation, selected by
innodb_native_foreign_keys, under which cascading updates and deletes do write row events for the child tables; sources before 9.6 don’t mark it in the binlog.9 If you’re on 8.4, runSELECT * FROM information_schema.REFERENTIAL_CONSTRAINTS WHERE DELETE_RULE = 'CASCADE'and assume those child tables are blind spots. - Half a transaction is worse than none. A feed can resume in the middle of a transaction. In a real restart, Debezium Server 3.3.1 re-sent BEGIN, skipped 48 rows and sent an END counting 252, which looked whole. Deedglass refuses to compare a transaction the feed holds only part of and calls it
feed_incomplete.11 Run the same command on that recording and the answer is Can’t check, exit 2, not a false Matches. - The feed, not the database. A result like this rests on what Debezium carried; a connector that filters or rewrites events changes what any reader sees. It is also offline: the Deedglass gateway has no MySQL connector, doesn’t wait for a feed, and the result isn’t sealed into its signed records.13
- MySQL syntax is read loosely. Statements go through a Postgres-dialect reader, so backtick-quoted names,
INSERT … ON DUPLICATE KEY UPDATEand multi-table statements come out asfootprint_unknown: Can’t check, not a verdict.13 - None of this stops anything. Every method here reports after the commit. Stopping a bad write is the job of the agent’s MySQL privileges and whatever checks the statement first.
A checklist
- Give each agent its own MySQL user, with only the grants its job needs. Every log below becomes readable when “who” is one name.
- Confirm
binlog_format=ROWandbinlog_row_image=FULL. - Turn on
binlog_rows_query_log_eventsso each row event can be tied to the statement that caused it. - Set
binlog_row_metadata=FULLif anything outside MySQL will read the binlog. - Copy binlogs off the server before
binlog_expire_logs_secondsremoves them, to storage the agent can’t write. - Turn on an audit plugin or component for the agent’s user, for the statement-level record.
- List every
ON DELETE CASCADEandON UPDATE CASCADEforeign key. On 8.4 those child tables are invisible to the binlog. - List every trigger (
information_schema.TRIGGERS) and the tables each one writes. - If you use Debezium, turn on
provide.transaction.metadataso you can tell a whole transaction from part of one. - For each agent action, compare rows committed per table with rows the statement named.
For the Postgres version of this comparison, with tested SQL for each method, read pgAudit vs triggers vs logical decoding. The same 301-row scene on Postgres, with the undo, is in Your AI agent said 1 row. Postgres changed 301, and you can watch it in a browser at /replay/one-row-update-changed-301. For why a log line differs from a record you can check, see why logs aren’t evidence. To see the gap on Postgres without installing anything, open the playground; to grade your own agent’s setup, run npx deedglass scan (what the grade means).
Questions people ask
Can I see who changed a row in MySQL after the fact?
Only if something recorded it at the time. Read through Debezium, a row event carries the MySQL thread id and, with binlog_rows_query_log_events on, the statement that wrote it.10 An audit plugin records who connected and what they ran.3 Matching the two by connection and time tells you which user’s statement changed the row. With nothing turned on, the answer is no.
Does the MySQL binlog include changes made by triggers?
Yes, in row format. Rows a trigger writes are logged as row changes, and replicas apply those rows rather than running the trigger again.14 Rows changed by InnoDB foreign key cascades on MySQL 8.4 are not logged.8
Is the general query log enough to audit an AI agent?
It shows what the agent sent, not what the statement changed. In the run above, the statement-level record is one UPDATE naming order 42, while the transaction holds 301 row changes.
Do I need Debezium to read the binlog?
No. mysqlbinlog -vv decodes row events and shows the original statements when they were logged. Debezium helps when you want the events as a stream, with transaction boundaries, outside the server.
Written with AI assistance and checked against our claims register.