Ir al contenido principal
Your Contract Agent Sends Duplicate Alerts. Here's Why.

Your Contract Agent Sends Duplicate Alerts. Here's Why.

AI Integration
6 min readPor Daily Miranda Pardo

The CFO opens their inbox. Three renewal alerts. Same vendor. Same amount. Same subject line. Sent at 3:00 AM, 3:01 AM, and 3:02 AM.

The agent worked. The problem is it worked three times.

This bug doesn't come from a poorly written prompt or the wrong model. It comes from an architecture that doesn't account for the scheduler running more than once, two workers reading the same state before either updates it, or date logic that returns true for days on end with no guard.

If you're building a monitoring agent — contracts, subscriptions, renewals, payment deadlines — this article saves you the embarrassing moment.

Monitoring Agents Are Not REST APIs

An HTTP endpoint is naturally idempotent: call it twice with the same data and the system ends up in the same state. Side effects don't accumulate.

A monitoring agent is the opposite. Every run has real side effects: it sends an email, writes to the database, calls an external API. Run it twice and those effects happen twice. The system is not left in the same state.

The difference seems obvious when stated this way. The problem is that most monitoring agent implementations are designed as if they were REST endpoints — stateless, no deduplication, trusting that "the scheduler won't call it twice." And the scheduler always calls it twice, eventually.

The Three Scenarios Where Alerts Duplicate

1. The Scheduler That Restarts at the Wrong Moment

When you use n8n, a Vercel cron, or any distributed scheduling system, the job can run more than once. A node that restarts mid-execution will relaunch it from the top. A deploy that overlaps the execution window can spin up the job in two instances simultaneously.

Result: two parallel runs find the same contract with alerted: false, both send the email, both update the state to alerted: true. Mission accomplished twice.

2. Date Logic That Evaluates true for Days

The most common mistake in renewal agents:

// This looks correct. It isn't.
const daysUntilRenewal = differenceInDays(renewalDate, new Date());

if (daysUntilRenewal <= 30) {
  await sendAlert(contract, '30d');
}

This condition is true on day 30, day 29, day 28, day 27... all the way to the expiry date. If the agent runs daily — as a cron job typically does — it sends a daily alert for 30 consecutive days.

3. The Database Race Condition

Two workers read the same record before either marks the alert as sent:

Worker A: SELECT * FROM contracts WHERE alerted_30d = false  → [contract-123]
Worker B: SELECT * FROM contracts WHERE alerted_30d = false  → [contract-123]
Worker A: sendEmail(contract-123)
Worker B: sendEmail(contract-123)  ← duplicate
Worker A: UPDATE contracts SET alerted_30d = true WHERE id = contract-123
Worker B: UPDATE contracts SET alerted_30d = true WHERE id = contract-123

Both reach the same conclusion because they read before writing. And in distributed systems, "read before write" is always a race condition waiting to happen.

The Fix: Dedup Key + Write-Before-Act

The correction requires two architectural changes that resolve all three scenarios simultaneously.

First: an alerts table with a unique constraint

CREATE TABLE contract_alerts (
  id          UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  contract_id UUID NOT NULL REFERENCES contracts(id),
  alert_type  TEXT NOT NULL CHECK (alert_type IN ('60d', '30d', '15d')),
  sent_at     TIMESTAMPTZ DEFAULT now(),
  UNIQUE (contract_id, alert_type)
);

The UNIQUE (contract_id, alert_type) constraint guarantees no two rows ever exist for the same combination. The database rejects the duplicate before any application code handles it.

Second: attempt insert before acting

async function sendRenewalAlert(
  contractId: string,
  alertType: '60d' | '30d' | '15d',
  contract: Contract
): Promise<void> {
  const { error } = await supabase
    .from('contract_alerts')
    .insert({ contract_id: contractId, alert_type: alertType });

  if (error?.code === '23505') {
    // Unique constraint violation: this alert was already sent.
    // Nothing to do.
    return;
  }

  if (error) throw error;

  // We only reach here if the insert succeeded.
  // We are the first to process this alert.
  await sendRenewalEmail(contract, alertType);
}

PostgreSQL error code 23505 is a unique constraint violation. If it fires, another process — or another run of the same process — already handled this alert. We do nothing.

This pattern resolves all three scenarios:

  • Restarting scheduler: the second run fails on insert, sends nothing
  • Date logic evaluating for days: the second day fails on insert, sends nothing
  • Race condition: the second worker fails on insert (the DB serializes the INSERT), sends nothing

Order Matters: The Rule Most Implementations Get Backwards

There's a fourth mistake that appears even when the dedup key is correctly implemented: sending the email before writing to the database.

// ❌ Wrong order
// If the email fails halfway, the DB stays unwritten.
// The agent retries and sends another email.
await sendRenewalEmail(contract);
await markAlertAsSent(contractId, alertType);

// ✅ Correct order
// The DB acts as an atomic "slot reservation."
// If insert fails → we don't act.
// If email fails after inserting → we have a record, no duplicate.
const inserted = await reserveAlertSlot(contractId, alertType);
if (inserted) {
  await sendRenewalEmail(contract);
}

The database is your source of truth. Write first. Act second.

If the action fails after writing, the system knows it "already tried" and won't retry. You can add controlled retry logic on top of that record, but you'll never be back in a state where "it looks like nothing happened" when something actually did.

What This Changes in Production

A monitoring agent built with these guarantees delivers at-most-once semantics for the effects that matter: the email arrives once, even if the job runs fifty times.

This is not a minor implementation detail. It's the difference between a system your CFO trusts and one that creates an embarrassing situation at the worst moment — usually when a critical renewal is approaching and your vendor receives three identical emails from the same company within two minutes.

The contract monitoring agent — with alerts at 60, 30, and 15 days before renewal — only works as promised when the underlying architecture has these guarantees. Otherwise, the agent exists, sends emails, and generates exactly the chaos it was meant to prevent.

If you're building this type of system and already have an agent in production that misbehaves, the AI integration service starts exactly there: auditing what already exists before adding more on top. You can also see how we structure these monitoring systems in the AI agents and automation service.

Is your agent behaving unpredictably in production? Tell me what's happening →

Compartir artículo

LinkedInXWhatsApp

¿Procesos repetitivos en tu empresa?

Descarga gratis el Mapa de Automatización IA — los 5 procesos que más tiempo roban y cómo resolverlos.

Sin spam. Solo el PDF. Puedes darte de baja cuando quieras.

Escrito por Daily Miranda Pardo

Ayudo a empresas a automatizar procesos, crear agentes IA y conectar sistemas inteligentes.