
You’re still running agents against MySQL. So am I. Last time we walked through what happens when an agent doesn’t understand the query it’s running. This week, what happens when MySQL can’t tell who the agent is in the first place.
Something deleted thirty thousand rows from
customersovernight. The DBA opens a terminal, runsSHOW PROCESSLIST, finds nothing useful. OpensINFORMATION_SCHEMA.PROCESSLIST. Same. Pulls the binlog for the last twelve hours, finds the DELETE. The connection identifier isapp_user@10.0.4.7. There are forty-three things behind that user. Three of them are agents. Two of those are background workers. One is a cron job. The DBA has no idea which of the forty-three did it.
This is the second of three promises. Manage Relationships. The original frame named three kinds of counterparty MySQL has to deal with: users, peer databases, and the dev/ops team. The relationship contract for users is built on identity. You know who connected. You know what they’re allowed to do. When something goes wrong, you know who to ask.
Most production MySQL setups today have a relationship contract that hasn’t caught up to what’s on the other side of the connection. Five different agents share one app_user. The cron job uses the same account. The background worker that processes payments uses the same account. The DBA’s read-only check script, when they’re tired, uses the same account.
That’s not a relationship. That’s a costume.
This post is about what’s already in MySQL to fix this and why most teams aren’t using it.
Identification: who is actually connected
MySQL has been collecting connection metadata for over a decade. Most teams never look at it.
Every connection passes a set of key-value attributes to the server at connect time. These live in performance_schema.session_connect_attrs. Run this from any session:
SELECT ATTR_NAME, ATTR_VALUE
FROM performance_schema.session_connect_attrs
WHERE PROCESSLIST_ID = CONNECTION_ID();
The output looks something like this:
+-----------------+----------------------+
| ATTR_NAME | ATTR_VALUE |
+-----------------+----------------------+
| _client_name | mysql-connector-python |
| _client_version | 8.0.32 |
| _pid | 14882 |
| _platform | x86_64 |
| _os | Linux |
| program_name | mysql |
+-----------------+----------------------+
The attributes prefixed with underscore are reserved by MySQL. The interesting one is program_name, which is yours to set. The Connector library exposes it. So does Connector/J, Connector/Node, and most others.
The actionable bit: when your agent connects, set program_name to something distinct.
import mysql.connector
conn = mysql.connector.connect(
host='db.internal',
user='agent_classifier',
password=os.environ['DB_PWD'],
connection_attributes={
'program_name': 'agent-classifier-v3',
'agent_id': 'classifier-worker-7',
'task_id': os.environ.get('TASK_ID', 'unknown'),
}
)
Now when the DBA runs the diagnostic query above against the suspicious connection, they see agent-classifier-v3 and classifier-worker-7 instead of just mysql-connector-python. Three weeks of debugging compress into one query.
There are gotchas. The aggregate size of connection attributes is capped at 64KB on both client and server. Long values get truncated and the truncation is logged in Performance_schema_session_connect_attrs_lost. If your agent infrastructure passes large task_id payloads with full prompt context, this bites. Keep attribute values short and put the heavy context in your application logs.
The other gotcha is that session_connect_attrs is per-session. If you want attribution that survives the connection closing, you need it in the binlog. Which brings us to the next part.
Attribution: which query came from which agent
Setting program_name identifies the connection. It does not identify which specific query inside that connection caused damage. For that, you need query-level attribution.
The cheapest way is SQL comments:
/* agent_id=classifier-7 task_id=batch-2026-05-11-0034 */
DELETE FROM stale_records WHERE created_at < '2024-01-01';
By default these comments are stripped by the server before parsing. To keep them in the binlog and the slow query log, two settings:
SET GLOBAL binlog_rows_query_log_events = ON;
SET GLOBAL log_slow_extra = ON;
binlog_rows_query_log_events makes the binlog capture the original SQL text for ROW-format binlog events. The Rows_query event includes the comment. When forensics needs to know which agent ran which query four months ago, this is the difference between an answer and a shrug.
log_slow_extra adds attribution-friendly fields to the slow query log including thread ID and bytes sent. Combined with the comment in the query text, you can correlate a slow query with the specific agent that issued it.
The cost: binlog size grows. ROW-format binlogs without binlog_rows_query_log_events only carry the row image. With it on, they carry the original SQL too. Plan for 20-40% more binlog volume depending on workload. For most production setups this is a fair trade. Run the math against your retention policy before flipping it.
Isolation: making blast radius proportional to scope
Once you can identify and attribute, the next question is what each agent is allowed to do.
The default pattern most teams have is one or two database accounts that the entire application uses. app_user for production reads and writes, readonly_user for analytics. Agents inherit one of those because nobody made a separate one for them.
That’s not bounded scope. That’s wide grants with extra users behind them.
The honest answer is one account per agent type. Not per agent instance, that’s too many. Per type. The classification agent gets agent_classifier. The reconciliation agent gets agent_reconciliation. The cleanup worker gets agent_cleanup. Each one’s grants reflect what it actually needs.
CREATE USER 'agent_classifier'@'%' IDENTIFIED BY '...';
GRANT SELECT, INSERT ON app.classifications TO 'agent_classifier'@'%';
GRANT SELECT ON app.documents TO 'agent_classifier'@'%';
That’s it. No DELETE. No grants on customers. No grants on orders. The classification agent reads documents and writes classifications. If a confused agent decides to clean up old test data, the database refuses. Not because of a guardrail. Because the privilege isn’t there.
There’s a feature in MySQL most teams have never used: per-account resource limits. They’ve existed since the 4.0 days and were originally designed for shared hosting providers. In the agent era they’re useful again.
CREATE USER 'agent_classifier'@'%' IDENTIFIED BY '...'
WITH MAX_QUERIES_PER_HOUR 10000
MAX_UPDATES_PER_HOUR 2000
MAX_USER_CONNECTIONS 4;
MAX_QUERIES_PER_HOUR caps total statements. MAX_UPDATES_PER_HOUR caps writes specifically. MAX_USER_CONNECTIONS caps simultaneous connections.
These limits are not bulletproof. Counting is per account, not per client, so an agent type that runs forty workers shares the budget across all of them. The hour rolls forward, not on a sliding window, so a thousand updates at minute 59 and another thousand at minute 1 of the next hour both succeed even though they happened ninety seconds apart. They’re a circuit breaker, not a firewall.
But a circuit breaker that trips at ten thousand queries per hour catches a runaway agent before it deletes the entire customers table. The runaway happens at the speed of LLM inference, which means three to ten queries per second per worker. Forty workers in a retry loop hit ten thousand queries in about ten minutes. The server starts rejecting them with ER_USER_LIMIT_REACHED. The damage stops at whatever was done in those ten minutes. If your alerting picks up the rejected query rate, you find out in real time.
You can reset the counters globally with FLUSH USER_RESOURCES once you’ve fixed whatever was broken.
To modify an existing account:
ALTER USER 'agent_classifier'@'%' WITH MAX_QUERIES_PER_HOUR 50000;
To remove a limit:
ALTER USER 'agent_classifier'@'%' WITH MAX_QUERIES_PER_HOUR 0;
There are sharper tools (ProxySQL with multiplexing, HAProxy with custom rules, application-level rate limiting). They’re all better in some way. But this one is in the database already and most teams aren’t using it. Set it before you go shopping for something else.
What MySQL still doesn’t have
Three things are missing from this stack and worth naming honestly.
There’s no native concept of “this connection is an agent.” program_name is a string the agent sets itself. A misbehaving or compromised agent can set it to anything. Identity is asserted by the client, not enforced by the server. For most operational purposes this is fine because the threat model is confused agents not malicious ones, but it should be named.
There’s no native correlation between a query and the parent task that produced it. SQL comments work but they’re a convention. They get stripped in many code paths. The binlog_rows_query_log_events setting keeps them in the binlog but a lot of tooling (replication filters, third-party CDC, some backup tools) doesn’t preserve them in transformed output.
There’s no time-windowed rate limit. MAX_QUERIES_PER_HOUR is per-clock-hour, not per-rolling-hour. The minute-59 / minute-1 problem above is a real failure mode. ProxySQL or application-level rate limiting fixes this. The server doesn’t.
These gaps are not arguments against using what’s there. They’re arguments for using what’s there and knowing where it ends.
The new diagnostic question
The original framework asked: who has access? Are the grants right?
Those questions are still good. They miss the case where the access model treats five different counterparties as the same one.
The new diagnostic question is shorter:
Can you trace this query back to a specific agent and a specific task in under thirty seconds?
If you can’t, the relationship is undesigned. The good news is that fixing it is a Tuesday afternoon, not a quarter-long refactor. Set program_name. Turn on binlog_rows_query_log_events. Create one account per agent type. Set conservative resource limits. None of it requires a schema change. None of it requires application code beyond connection setup. All of it is in the manual.
The promise still holds. The database still manages relationships. What was missing was an honest count of how many counterparties were on the other side.
Part 4 is on Survive. It’s the hardest one and where the fights about how to defend MySQL in the agent era really start. Coming Soon.
If you’ve set up agent-aware accounts and grants in production, especially with resource limits on, write. The patterns that hold up under load are the part of this argument that will get richer the more cases people contribute.