How to audit MySQL changes made by an AI agent: statements, rows and the gaps

Five ways to see what an agent did to a MySQL database, what each one records, and a recorded run where the agent's statement says one row and the binlog says 301.

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.

Each action ends in a signed record: what ran, who approved it, what the check found. Records are chained, each one carrying the fingerprint of the one before, and they can be checked offline. Made-up record.

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. minimal logs 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 -vv shows it.
  • binlog_row_metadata=FULL (default MINIMAL) 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:

  1. 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.
  2. DROP TABLE and TRUNCATE TABLE don’t fire DELETE triggers.6 The most destructive things an agent can run are the ones the trigger never sees.
  3. 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, run SELECT * 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 UPDATE and multi-table statements come out as footprint_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

  1. Give each agent its own MySQL user, with only the grants its job needs. Every log below becomes readable when “who” is one name.
  2. Confirm binlog_format=ROW and binlog_row_image=FULL.
  3. Turn on binlog_rows_query_log_events so each row event can be tied to the statement that caused it.
  4. Set binlog_row_metadata=FULL if anything outside MySQL will read the binlog.
  5. Copy binlogs off the server before binlog_expire_logs_seconds removes them, to storage the agent can’t write.
  6. Turn on an audit plugin or component for the agent’s user, for the statement-level record.
  7. List every ON DELETE CASCADE and ON UPDATE CASCADE foreign key. On 8.4 those child tables are invisible to the binlog.
  8. List every trigger (information_schema.TRIGGERS) and the tables each one writes.
  9. If you use Debezium, turn on provide.transaction.metadata so you can tell a whole transaction from part of one.
  10. 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.

The takeaway

MySQL’s query log shows what an agent sent; the binlog’s row events show what it changed, and in our run that was 301 rows, not one.

Read nextYour AI agent said it updated 1 row. Postgres changed 301. Try it: install in 7 s

Related on this site

More posts

Sources

  1. 1MySQL 8.4 manual: The general query log · dev.mysql.com · checked 25 October 2026
  2. 2MySQL 8.4 manual: Binary logging options and variables · dev.mysql.com · checked 25 October 2026
  3. 3MySQL 8.4 manual: MySQL Enterprise Audit · dev.mysql.com · checked 25 October 2026
  4. 4Percona Server for MySQL 8.4: Audit Log Filter overview · docs.percona.com · checked 25 October 2026
  5. 5MariaDB documentation: MariaDB Audit Plugin · mariadb.com · checked 25 October 2026
  6. 6MySQL 8.4 manual: CREATE TRIGGER · dev.mysql.com · checked 25 October 2026
  7. 7MySQL 8.4 manual: Restrictions on stored programs · dev.mysql.com · checked 25 October 2026
  8. 8MySQL 8.4 manual: InnoDB and MySQL replication · dev.mysql.com · checked 25 October 2026
  9. 9MySQL 9.7 manual: Replication and foreign keys · dev.mysql.com · checked 25 October 2026
  10. 10Debezium documentation: MySQL connector · debezium.io · checked 25 October 2026
  11. 11deedglass/deedglass: docs/WITNESS_DEBEZIUM.md · github.com · checked 25 October 2026
  12. 12deedglass/deedglass: the recorded MySQL 8.4 feeds · github.com · checked 25 October 2026
  13. 13deedglass/deedglass: docs/LIMITATIONS.md, foreign change feeds · github.com · checked 25 October 2026
  14. 14MySQL 8.4 manual: FAQ on triggers · dev.mysql.com · checked 25 October 2026
  15. 15MySQL 8.4 manual: The binary log · dev.mysql.com · checked 25 October 2026

Published 25 October 2026, sources last checked 25 October 2026. If something here is out of date, write to hello@deedglass.com.

Try it on your own setup

npx deedglass scan

Grades only your own setup. Nothing is uploaded.

Pricing