Skip to content
AI agents

Where AI agents break MongoDB, and the guards that hold

Four ways an AI agent corrupts a MongoDB database duplicate retries, lost updates, injected filters, admin credentials and the guard for each.

K10 min read
Cable network — Taylor Vick

An AI agent is a database client that retries without being asked, writes query filters from text it was handed, and guesses field names it has never seen. None of the failures that follow are new. The agent just reaches them faster than a human-written client would.

Developers already act as if they know this. In Stack Overflow's April 2026 pulse survey of about 1,100 respondents, 59% used agents at work, yet 60% block agents from making unapproved system changes and 63% rarely or never let them run on autopilot (Stack Overflow, May 2026). This article puts that caution where it belongs: in the queue, the write, the schema and the role. Each section shows the code that breaks, why, and the guard that holds. Examples use Node.js, the official mongodb driver, BullMQ and Zod 4.

AgentLLMTool layerZodBullMQWorkerMongoDBroles + validatortool calljobIdretriesagent user
Each guard sits where its failure starts.

Why a retried job writes twice

Retries reach the database from three places at once. The agent framework calls a tool again when a response times out. BullMQ re-runs a job after a failure if attempts is set, and moves a stalled job back to waiting (BullMQ: stalled jobs). And a person, seeing nothing happen, clicks again. If every attempt looks like new work, every attempt writes.

src/worker.tsTypeScript
import { Worker } from 'bullmq';import { MongoClient } from 'mongodb'; const client = new MongoClient(process.env.AGENT_MONGO_URI!);const actions = client.db('ecommerce').collection('agent_actions'); new Worker('agent-tasks', async (job) => {  const { userId, action } = job.data;  // A new document on every attempt: three attempts, three rows.  await actions.insertOne({ userId, action, createdAt: new Date() });}, { connection: { host: 'localhost', port: 6379 } });

Nothing in that write says which piece of work it belongs to, so neither the queue nor the database can tell a retry from a second request.

Name the job after the work

Give the job an id built from the thing being worked on and its revision. The same draft at the same updatedAt always produces the same id. BullMQ ignores an add whose id already exists in the queue (BullMQ: job ids), so a second enqueue of the same work does nothing.

src/queue.tsTypeScript
import { Queue } from 'bullmq'; const connection = { host: 'localhost', port: 6379 };export const agentQueue = new Queue('agent-tasks', { connection }); export async function enqueueAgentAction(draftId: string, updatedAt: Date, action: string) {  // Same draft revision, same id. Dots, not colons: see the warning below.  const jobId = `agent.${draftId}.${updatedAt.getTime()}`;   await agentQueue.add('apply-action', { draftId, action }, {    jobId,    attempts: 3,    backoff: { type: 'exponential', delay: 2000 },    // Keep finished jobs for a day so a late duplicate still collides.    removeOnComplete: { age: 24 * 60 * 60 },    removeOnFail: { age: 7 * 24 * 60 * 60 },  });}

attempts and backoff are job options, passed to add or set as the queue's defaults (BullMQ: retrying failing jobs). Putting them on the Worker, as many examples do, has no effect on retries.

Two other things often copied from older examples break here. QueueScheduler was removed in BullMQ 2.0 (BullMQ 2.0 announcement), so importing it fails on current versions. And autorun: false on a worker does not deduplicate anything: it stops the worker from processing until you call worker.run() (BullMQ: workers).

Make the write carry the key

The job id stops duplicate enqueues. It does not stop a job that crashes half-way and runs again (Railway: webhooks at scale). For that, the write itself has to carry the key, and the database has to enforce it. The _id index is unique on every collection, and inserting an existing _id fails with error code 11000 (MongoDB: insertOne).

src/worker.tsTypeScript
import { Worker } from 'bullmq';import { MongoClient, MongoServerError } from 'mongodb'; type AgentAction = { _id: string; draftId: string; action: string; createdAt: Date };type AgentJob = { draftId: string; action: string }; const client = new MongoClient(process.env.AGENT_MONGO_URI!);const actions = client.db('ecommerce').collection<AgentAction>('agent_actions'); const worker = new Worker<AgentJob>(  'agent-tasks',  async (job) => {    const { draftId, action } = job.data;    try {      // The job id is the document id: a second attempt hits the unique _id index.      await actions.insertOne({ _id: job.id!, draftId, action, createdAt: new Date() });    } catch (err) {      if (err instanceof MongoServerError && err.code === 11000) return 'already-done';      throw err;    }    return 'done';  },  { connection: { host: 'localhost', port: 6379 } },); worker.on('error', (err) => console.error('worker error', err));

Two details in the usual version of this pattern break at runtime. Wrapping the key in new ObjectId(jobId) throws, because an ObjectId must be built from a 24-character hex string, 12 bytes, or an integer (MongoDB forums). A string _id is fine. And checking first, then inserting, leaves a gap between two operations that a parallel call can walk through:

src/worker.tsDiff
- const existing = await actions.findOne({ _id: new ObjectId(jobId) });- if (existing) return;- await actions.insertOne({ _id: new ObjectId(jobId), ...rest });+ // One operation; the unique index decides, not a read that may be stale.+ await actions.insertOne({ _id: job.id!, ...rest }); // catch code 11000

The same flaw sinks a subtler version: recording failures in the same collection under the same key. The next attempt finds the failure record and skips the work as "already done", so a job that failed once never succeeds. Record only success under the idempotency key; let BullMQ keep the failure.

When the work is more than one write

A marker document only helps if it lands together with the effect it describes. If the worker adjusts stock and then crashes before writing the marker, the retry adjusts stock again. Put both writes in one transaction, so either both commit or neither does (MongoDB Node.js driver: transactions).

src/apply-stock-adjustment.tsTypeScript
import { MongoClient, MongoServerError } from 'mongodb'; const client = new MongoClient(process.env.AGENT_MONGO_URI!);const db = client.db('ecommerce');const actions = db.collection<{ _id: string; appliedAt: Date }>('agent_actions');const products = db.collection<{ _id: string; stock: number }>('products'); export async function applyStockAdjustment(jobId: string, productId: string, delta: number) {  const session = client.startSession();  try {    await session.withTransaction(async () => {      await actions.insertOne({ _id: jobId, appliedAt: new Date() }, { session });      await products.updateOne({ _id: productId }, { $inc: { stock: delta } }, { session });    });    return 'applied';  } catch (err) {    if (err instanceof MongoServerError && err.code === 11000) return 'already-applied';    throw err;  } finally {    await session.endSession();  }}

If the effect is outside MongoDB — an email, a payment, a call to another API — no transaction covers it. Pass the job id to that service as its idempotency key if it accepts one; if it does not, the crash window between the call and the marker cannot be closed from your side.

Why two agents overwrite each other

Agents are slow writers. Between reading a document and saving their result sits a model call, and during that time another agent, or a person, can change the same document. The read-modify-write below saves whatever the agent saw, not what is there now.

src/process-request.tsTypeScript
const request = await requests.findOne({ _id: requestId });// ...a model call happens here...await requests.updateOne(  { _id: requestId },                       // matches whatever is there now  { $set: { ...request, status: 'processing' } }, // and writes back the old copy);

Two agents that read the same pending request both mark it processing, both do the work, and the second save silently discards any field the first one changed.

Let the filter do the claiming

Put the condition in the filter and the change in the update, in one call. Every write to a single document in MongoDB is atomic (MongoDB Node.js driver: transactions), and findOneAndUpdate reads and writes in one statement with no gap between them (MongoDB Node.js driver: compound operations). Only one agent's filter can match status: 'pending'.

src/claim-request.tsTypeScript
import { MongoClient, ObjectId } from 'mongodb'; type Request = {  _id: ObjectId;  status: 'pending' | 'processing' | 'done';  claimedBy?: string;  claimedAt?: Date;  summary?: string;  version: number;}; const client = new MongoClient(process.env.AGENT_MONGO_URI!);export const requests = client.db('ecommerce').collection<Request>('requests'); export async function claimRequest(requestId: ObjectId, agentId: string) {  // null means another agent claimed it first: stop, do not retry the claim.  return requests.findOneAndUpdate(    { _id: requestId, status: 'pending' },    { $set: { status: 'processing', claimedBy: agentId, claimedAt: new Date() }, $inc: { version: 1 } },    { returnDocument: 'after' },  );}

Version the document when the change is computed

Claiming covers state transitions. When the agent computes a new value from what it read, such as a summary, guard the write with the version it read. A version check does not stop two agents working at once; it makes the stale one's write fail instead of win.

src/save-summary.tsTypeScript
import type { ObjectId } from 'mongodb';import { requests } from './claim-request'; export class StaleWriteError extends Error {  constructor(id: ObjectId) {    super(`request ${id.toHexString()} changed since it was read`);  }} export async function saveSummary(requestId: ObjectId, summary: string, readVersion: number) {  const res = await requests.updateOne(    { _id: requestId, version: readVersion },     // only the version we read    { $set: { summary }, $inc: { version: 1 } },   // only the fields we changed  );  if (res.matchedCount === 0) throw new StaleWriteError(requestId);}

On StaleWriteError, re-read and decide: recompute, merge, or hand the conflict to a person. Retrying the same write with the same stale input only fails again.

Why model output is untrusted input

Tool arguments come from a model, and the model's input includes whatever it read: a web page, an email, a support ticket. Treat the arguments exactly as you would a request body. Two things go wrong when you do not.

The first is operator injection. A lookup tool that passes its argument straight into a filter does what the argument says:

src/tools/find-user.tsTypeScript
// Tool arguments from the model: { "email": { "$ne": null } }const user = await users.findOne({ email: args.email }); // returns the first user

The second is shape drift. The model sends "age": "42", invents a fullName field the schema never had, or nests an object where a string belongs. Nothing fails at write time; the bad documents surface weeks later in a report or a crash.

Parse tool arguments with a strict schema

Parse every tool call against a schema that allows only primitives where a filter expects them and rejects unknown keys. An object cannot pass where a string is required, so { "$ne": null } never reaches the query.

src/tools/users.tsTypeScript
import { z } from 'zod';import { MongoClient } from 'mongodb'; const client = new MongoClient(process.env.AGENT_MONGO_URI!);const db = client.db('ecommerce');const users = db.collection('users');const usersPublic = db.collection('users_public'); // a view, see the last section const Email = z.string().trim().toLowerCase().pipe(z.email());// No "$" and no "." in keys a model chooses.const PreferenceKey = z.string().regex(/^[A-Za-z0-9_-]{1,40}$/); const CreateProfileArgs = z.strictObject({  email: Email,  age: z.number().int().min(0).max(150),  preferences: z    .record(PreferenceKey, z.union([z.string().max(200), z.number(), z.boolean()]))    .default({}),}); const FindUserArgs = z.strictObject({ email: Email }); function formatIssues(error: z.ZodError) {  return error.issues.map((i) => `${i.path.map(String).join('.')}: ${i.message}`).join('; ');} export async function createProfile(rawArgs: unknown) {  const parsed = CreateProfileArgs.safeParse(rawArgs);  // Send the issues back to the agent so it can correct the call itself.  if (!parsed.success) return { ok: false as const, error: formatIssues(parsed.error) };   const now = new Date();  await users.insertOne({ ...parsed.data, createdAt: now, updatedAt: now });  return { ok: true as const };} export async function findUser(rawArgs: unknown) {  const parsed = FindUserArgs.safeParse(rawArgs);  if (!parsed.success) return { ok: false as const, error: formatIssues(parsed.error) };  return { ok: true as const, user: await usersPublic.findOne({ email: parsed.data.email }) };}

age is deliberately not coerced. z.coerce.number() runs Number() on the input, and Number(null) and Number('') are both 0, so a missing age becomes a newborn. Returning the error to the agent costs one more tool call and keeps the data honest.

Do not rely on a sanitiser

A hand-written sanitiser that strips keys starting with __ and trims strings does nothing about $ne, $gt or $where; it filters the wrong prefix. Even well-maintained sanitisers miss cases. Mongoose's sanitizeFilter wraps objects in a filter with $eq to neutralise injected operators (Mongoose: migrating to 6), and in 2026 it was found not to recurse into $nor, letting operators through (CVE-2026-42334, GitHub advisory). The advisory notes that applications which validate input schemas or whitelist fields were not affected. A strict schema is the guard; a sanitiser is a second line.

Let the server reject invented fields

Application validation covers the code paths you remembered to validate. A $jsonSchema validator on the collection covers every write, including the script someone runs by hand next month. With additionalProperties: false, MongoDB rejects documents carrying fields the schema does not list — and because every document has an _id, you must list _id too, or every insert fails (MongoDB: tips for JSON Schema validation).

scripts/users-validator.tsTypeScript
import { MongoClient } from 'mongodb'; async function main() {  const client = new MongoClient(process.env.ADMIN_MONGO_URI!);  try {    await client.db('ecommerce').command({      collMod: 'users',      validator: {        $jsonSchema: {          bsonType: 'object',          required: ['_id', 'email', 'createdAt'],          additionalProperties: false,          properties: {            _id: { bsonType: 'objectId' },            email: { bsonType: 'string' },            age: { bsonType: 'number', minimum: 0, maximum: 150 },            preferences: { bsonType: 'object' },            createdAt: { bsonType: 'date' },            updatedAt: { bsonType: 'date' },          },        },      },      validationLevel: 'strict',      validationAction: 'error',    });  } finally {    await client.close();  }} main().catch((err) => {  console.error(err);  process.exit(1);});

collMod changes an existing collection; for a new one, pass the same validator to createCollection. A rejected write throws MongoServerError with the message "Document failed validation" (MongoDB: specify JSON Schema validation). Run it with validationAction: 'warn' first on a collection that already holds data, so you find the old documents that would now fail before the agent does.

Why the agent's credentials decide the worst case

Every guard above assumes the code runs as written. Credentials decide what happens when it does not. An agent connected as an administrator can do anything a typo or a prompt injection asks: drop a database, empty a collection, read the password hashes. (The usual example of this, collection('users').dropDatabase(), would not even run — dropDatabase belongs to Db, not Collection — but db.dropDatabase() would.)

The agent's role is the blast radius. Everything else only makes a mistake less likely.

Give the agent a role, not a filter

MongoDB roles grant actions on named collections, and a view counts as a collection for that purpose. To hide fields, grant find on a view that projects only safe fields and grant nothing on the collection behind it; MongoDB's own documentation uses this pattern to give a billing role a creditCard field and a provider role a diagnosisCode field from the same data (MongoDB: create and query a view).

scripts/agent-roles.jsJavaScript
// Run as an admin: mongosh "$ADMIN_MONGO_URI" scripts/agent-roles.jsconst app = db.getSiblingDB('ecommerce'); // Inclusion projection: a sensitive field added to users later stays hidden.app.createView('users_public', 'users', [  { $project: { email: 1, preferences: 1, createdAt: 1 } },]); app.createRole({  role: 'agentReader',  privileges: [    { resource: { db: 'ecommerce', collection: 'users_public' }, actions: ['find'] },    { resource: { db: 'ecommerce', collection: 'products' }, actions: ['find'] },    { resource: { db: 'ecommerce', collection: 'orders' }, actions: ['find'] },  ],  roles: [],}); app.createRole({  role: 'agentWriter',  privileges: [    { resource: { db: 'ecommerce', collection: 'users' }, actions: ['insert'] },    { resource: { db: 'ecommerce', collection: 'agent_actions' }, actions: ['find', 'insert'] },    { resource: { db: 'ecommerce', collection: 'requests' }, actions: ['find', 'update'] },    { resource: { db: 'ecommerce', collection: 'products' }, actions: ['update'] },  ],  roles: [],}); app.createUser({  user: 'agent',  pwd: process.env.AGENT_DB_PASSWORD,  roles: ['agentReader', 'agentWriter'],});

The agent connects with its own URI, AGENT_MONGO_URI, and nothing in its code changes. No remove, no dropCollection, no access to users beyond inserting. If a tool needs more, the role change is a reviewed diff, not a line an agent can talk itself into.

An application-level wrapper with allow-lists and field filters is still useful as a second layer and for logging, but it fails quietly in ways a role does not. Two common bugs: filter helpers that return a cleaned copy which the caller then ignores, so nothing is filtered; and recursive filters built on { ...value }, which turn every Date into {} because a Date has no own enumerable properties.

Give the agent tools, not a query

The narrowest interface is a function that answers one question. A tool that takes a collection name and a filter hands the model the whole query language; a tool that takes an order id hands it one lookup.

src/tools/order-status.tsTypeScript
import { z } from 'zod';import { MongoClient, ObjectId } from 'mongodb'; const client = new MongoClient(process.env.AGENT_MONGO_URI!);const orders = client.db('ecommerce').collection('orders'); const Args = z.strictObject({ orderId: z.string().regex(/^[a-f0-9]{24}$/) }); export async function getOrderStatus(rawArgs: unknown) {  const { orderId } = Args.parse(rawArgs);  return orders.findOne(    { _id: new ObjectId(orderId) },    { projection: { _id: 0, status: 1, updatedAt: 1 } },  );}

The regex guarantees the ObjectId constructor gets 24 hex characters, the projection limits what comes back, and the role limits what the call could do even if both were wrong.

What to check before an agent touches the database

  • Every agent job has a jobId derived from the work and its revision, with no : and not only digits.
  • Completed jobs are kept at least as long as a duplicate could arrive.
  • Every agent write carries a key the database enforces — _id or a unique index — and handles code 11000.
  • Effect and marker commit in one transaction when the work is more than one write.
  • State changes go through a conditional filter or a version check, never read-modify-write.
  • Tool arguments pass a strict schema before they reach a filter; errors go back to the agent.
  • Collections the agent writes to have a $jsonSchema validator.
  • The agent connects as its own user, with roles scoped to the views and collections its tools need.

If you only do one of these, do the last. A retry, a stale write or an injected filter can only do what the agent's role allows.

Found this useful? Share itXLinkedIn