← All articles

GTM Engineering

The Afternoon Flow That Silently Corrupted 3,100 Accounts

An LLM-generated Flow shipped in an afternoon with no idempotency and no fault path. Six months later it had overwritten 3,100 accounts and nobody saw it. The incident report, the broken flow, and the hardened version.

· 14 min read

Incident report, filed 182 days too late. A record-triggered Flow that took one afternoon to build had, by the time anyone noticed, overwritten the account owner field on 3,100 accounts, reassigned pipeline underneath live deals, and misrouted six months of inbound because the routing rules read a field the Flow kept resetting. No alert fired. No test failed. The dashboard stayed green the entire time. A rep noticed her account had a new owner she had never heard of, and that one ticket unwound a half-year of quiet corruption.

Nobody wrote a bug in the usual sense. Someone described the outcome to a model, the model produced a plausible Flow, it passed the one manual test that was run, and it shipped. That is the pattern I want to name. Not the model. The habit of shipping model-generated automation into a system of record without the review a human-written version would have gotten. The Flow worked the day it launched. It broke slowly, in a place nobody was looking, and it took a customer-facing symptom to surface it.

3,100
Accounts silently overwritten
182 days
Before anyone noticed
0
Idempotency or fault guards in the original

There was no crash to graph. The only line that moved was the count of quietly overwritten accounts, climbing week after week while the error log sat at zero. Scroll the curve and watch the damage accrue against days since the Flow shipped. The steep jump is the day a routine data load hit 900 records at once. Nothing in the org reported a problem the entire time the line was rising.

Silent corruption vs days since shipNo guard, so the damage compounds where nobody is looking
0825165024753300Day 0Day 20Day 41Day 90Day 120Day 182Days since the Flow shipped
0Day 0Shipped after one happy-path test. Log clean.
The count of overwritten accounts against days since ship. Errors logged stayed at zero because the Flow had no fault path. This is the curve the pre-ship review gate at the end of this piece exists to flatten to zero.

The incident, in the order it happened

Here is the timeline as we reconstructed it from field history and the debug logs we turned on after the fact.

DayEventState
0Flow shipped to production after one happy-path testGreen
1 to 40Fires on normal edits, overwrites owner on a handful of records per dayGreen
41A data load touches 900 accounts; Flow fires on all of themGreen
120Routing starts sending leads to owners the Flow inventedGreen
182A rep escalates an account she does not recognizeRed
183Field history pulled; 3,100 accounts show the same automated actorRed

The word that should bother you is “green” sitting on top of a red process for 181 of those days. The Flow had no error handling, so it never threw. It had no idempotency, so every re-fire compounded. And it wrote silently, so the only monitor that would have caught it was one nobody built. This is the same failure class as an enrichment job that runs twice and double-charges you, except the currency here is your system of record instead of enrichment credits.

What “vibe coding” ships in production

Vibe coding is the practice of generating a Flow, an Apex trigger, or a sync script from a natural-language prompt and shipping it on the strength of “it ran once and did the thing I asked.” The model is good at the happy path. It is good precisely because the happy path is what most training examples show. What the model does not do, unless you force it, is reason about the second run, the partial failure, the null it never saw, and the record that arrives already in the target state.

The context matters here. In scaled-AI surveys through 2025, roughly 88% of teams reported an AI pilot in flight while only about 38% had moved anything to production scale (scaled-AI surveys, 2025). The gap between those two numbers is mostly the work I am describing: the hardening that turns a demo into a system. Vibe coding skips exactly that gap and ships the demo.

What the demo provesWhat production needsSource on the cost of the gap
It runs once, correctlyIt runs 10,000 times, idempotentlyBad data costs the average org ~$15M/yr (Gartner)
It handles the record you testedIt handles the null, the dup, the re-fireFirmographic data decays ~30%/yr (ZoomInfo)
It looks right in the builderIt fails loud, not silent, when it breaksAI at scale: ~38% vs ~88% in pilot (scaled-AI surveys, 2025)

The three things the afternoon flow skipped

No entry guard, so it fired on everything. The Flow triggered on every account update with no condition. Change a phone number, the Flow runs. Re-parent a hierarchy, the Flow runs on every child. A data load of 900 rows is 900 firings. The model was asked to “update the owner when the segment changes” and produced a Flow that ran on all updates and then checked nothing, so a segment field that had not changed still triggered a recompute and a rewrite.

No idempotency, so re-fires compounded. The Flow set the owner from a formula. Run it once, you get an owner. Run it again on the same record with the same inputs and it writes the same value, which sounds safe until the formula depends on a field another automation is also editing. Two automations, each firing on the other’s write, is a recompute loop. Field history showed the same account owner-stamped four times in ninety seconds.

No fault path, so failures were invisible. When a downstream validation rejected a write, the Flow had no fault connector. In Salesforce a Flow without a fault path does not surface the error to a human; the transaction rolls back and the operation looks like it never happened. Some writes landed, some silently did not, and the data drifted into a state where half the record was updated and half was not.

The broken flow, as generated

Here is the shape of what shipped, transcribed from the Flow into readable config. The prompt was “when an account’s segment changes, set the owner to the segment’s default owner and notify them.” The model produced this.

# broken.flow: generated, shipped same afternoon
trigger:
  object: Account
  type: record-triggered
  when: on-create-and-update      # fires on EVERY update
  entry_conditions: none          # no ISCHANGED guard
recordLookup:
  find: Segment_Default_Owner__c where Segment__c = {!$Record.Segment__c}
recordUpdate:
  target: $Record
  set:
    OwnerId: {!lookup.Default_Owner__c}   # overwrites whatever is there
  # no check that OwnerId already equals the target
  # no fault connector
action:
  send_email: to {!lookup.Default_Owner__c}   # fires every run

Three defects, each a single missing line. It runs on every update instead of only when Segment__c truly changes. It overwrites OwnerId without checking whether the record is already in the target state, so a re-fire is a fresh write, not a no-op. And there is no fault connector, so a rejected write disappears. The email action has the same problem the write does: it fires on every run, so the “new” owner got pinged repeatedly for accounts they had owned for months.

The hardened version, one guard at a time

The fix is not a rewrite. It is three guards the model omitted. Same logic, made safe to run 10,000 times.

# hardened.flow: same intent, safe on re-run
trigger:
  object: Account
  type: record-triggered
  when: on-create-and-update
  entry_conditions:                       # GUARD 1: only when it changed
    - ISCHANGED({!$Record.Segment__c}) = true
    - NOT(ISBLANK({!$Record.Segment__c}))
recordLookup:
  find: Segment_Default_Owner__c where Segment__c = {!$Record.Segment__c}
  on_no_match: route-to-fault             # GUARD 3a: handle the miss
decision:
  # GUARD 2: idempotency, skip if already in target state
  already_correct: {!$Record.OwnerId} = {!lookup.Default_Owner__c}
  if already_correct: end                 # second run is a no-op
recordUpdate:
  target: $Record
  set:
    OwnerId: {!lookup.Default_Owner__c}
  fault_connector: log-and-alert          # GUARD 3b: fail loud
action:
  send_email: to {!lookup.Default_Owner__c}
  gate: only when OwnerId transitioned     # fire on change, not presence

If the rule ever needs to live in Apex instead of a Flow, the same three guards translate directly. This is the before-save version, which also fixes the recompute loop because before-save assignments do not re-trigger the Flow.

// Account before-save: idempotent owner assignment
for (Account a : Trigger.new) {
    Account old = Trigger.isUpdate ? Trigger.oldMap.get(a.Id) : null;

    // GUARD 1: only act when Segment truly changed
    Boolean changed = old == null || a.Segment__c != old.Segment__c;
    if (!changed || String.isBlank(a.Segment__c)) continue;

    Id target = ownerBySegment.get(a.Segment__c);
    if (target == null) {          // GUARD 3: handle the miss, do not guess
        a.addError('No default owner for segment ' + a.Segment__c);
        continue;
    }
    // GUARD 2: idempotent, write only on a real transition
    if (a.OwnerId != target) {
        a.OwnerId = target;
        ownersToNotify.add(target);   // notify on transition, not on every save
    }
}

The decision node doing the OwnerId equals target check is the whole idempotency story. It turns the second run into a no-op, which is the one property that would have stopped the data-load day from stamping 900 records and the recompute loop from stamping the same account four times.

The cascade No guard, so one edit becomes a loop and a silent overwrite
Generated: any edit becomes a rewrite loopAny account editFlow fires (no guard)Overwrite OwnerIdre-triggers itself; no fault path; silentHardened: change check, state check, fail loudSegment changed?Already correct? stopWrite once; fault to alertNotify on change
The generated Flow (top) fires on any edit, rewrites the owner, and re-triggers itself with no fault path. The hardened Flow (bottom) checks the change, checks the target state, and routes failures to an alert.

What the monitoring showed versus what was happening

The most useful artifact from the postmortem is this: a chart of what the error log reported against what was corrupting in the data. The two lines are the whole lesson. The system said nothing was wrong for six months while the count of damaged records climbed every week.

Silent corruption: what we saw vs what was true
The error log stayed near zero because the Flow had no fault path. The real count of overwritten accounts climbed the entire time. The gap between the two lines is the cost of shipping without instrumentation.
View as table
PointValue
M1180
M2620
M31,150
M42,040
M52,610
M63,100

The pre-ship review gate

The lesson is not “stop using models.” I use them daily to draft Flows and Apex. The lesson is that generated automation earns the same review a human’s pull request would get, and that review is a fixed checklist. Here is the gate I run before any model-generated automation touches production. It maps one-to-one to the three defects above, plus the two that catch everything else.

The pre-ship review gate for generated automation
  1. 1

    Prove the entry condition is narrow

    Confirm the trigger fires only on the change it claims to handle. Read the entry criteria; an ISCHANGED or a specific field condition must be present. A record-triggered automation with no entry condition is a defect, not a default.

  2. 2

    Run it twice and diff the record

    Fire the automation on one record, snapshot the fields, fire it again with no input change, snapshot again. If any field moves on the second run, it is not idempotent and it will compound under retries and data loads.

  3. 3

    Force a failure and watch where it goes

    Feed it a null, a missing lookup, a value that trips a downstream validation. A hardened automation routes that to a fault path and alerts a human. A vibe-coded one swallows it. If you cannot make it fail loud on demand, it will fail silent in production.

  4. 4

    Test on a bulk transaction, not one record

    Run it against a 200-record load, the size a data import or a mass update produces. The happy-path single-record test is exactly the test that passed before the day-41 load corrupted 530 accounts. Bulk is where the entry-guard and governor-limit defects surface.

  5. 5

    Instrument the silent path before launch

    Add the monitor that would have caught this: a report or a scheduled check on the field the automation writes, alerting when the rate of change exceeds a threshold. If the only way to detect a break is a customer noticing, you have shipped without instrumentation.

Steps two and three are the ones vibe coding always skips, because the model is optimizing for “does the thing I asked” and the person is optimizing for “ship it this afternoon.” Neither of them is optimizing for the second run or the null. That is your job, and it is the job that does not compress into a prompt.

Broken versus hardened, side by side

The afternoon flow After the review gate
Fires when Any account edit Only when Segment truly changes
Second identical run Rewrites the field again No-op, already-correct check stops it
A rejected write Silently rolls back, no signal Routes to fault path and alerts
900-record data load 900 firings, 530 corrupted Fires only on the rows that changed
How you find a break A customer escalates 182 days later A threshold monitor pages you the same day
Same intent, same builder, same model draft. The difference is three guards and one monitor, about two hours of work against 103 hours of remediation.

Why this is a GTM engineering problem, not an IT one

The reason this lands on the GTM engineer and not a platform team is that the blast radius is revenue, not infrastructure. A corrupted owner field is a misrouted lead, a comp dispute, a rep working an account that belongs to someone else, and a forecast rolled up under the wrong manager. The 3,100 accounts here did not crash anything. They quietly degraded routing accuracy and territory integrity for a full quarter, which is the kind of damage that never shows up as an outage and always shows up in a QBR as “why is our conversion down.”

Treat every generated automation as an untested pull request from a fast, confident junior who has never seen your production data. The code is often good. The judgment about your system, the nulls your data really contains, the other automations already fighting over the same field, is not in the prompt and cannot be. That judgment is the governance layer that separates a GTM engineer from someone who pastes model output into a Flow builder. Ship the draft through the gate, not around it.

The next time a Flow takes one afternoon to build, spend the second afternoon on the review gate. Run it twice, force it to fail, load it with 200 records, and add the monitor. Two hours against 103 hours of remediation and a quarter of misrouted pipeline is the trade, and it is not close.

flows automation data-quality

Keep reading

One email. Every week.

One email a week: a system I built or broke, with the config, the numbers, and what I would change. No roundups, no theory, unsubscribe whenever it stops being useful.

The newsletter opens soon.

Connect a provider in src/config.ts