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.
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.
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.
| Day | Event | State |
|---|---|---|
| 0 | Flow shipped to production after one happy-path test | Green |
| 1 to 40 | Fires on normal edits, overwrites owner on a handful of records per day | Green |
| 41 | A data load touches 900 accounts; Flow fires on all of them | Green |
| 120 | Routing starts sending leads to owners the Flow invented | Green |
| 182 | A rep escalates an account she does not recognize | Red |
| 183 | Field history pulled; 3,100 accounts show the same automated actor | Red |
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 proves | What production needs | Source on the cost of the gap |
|---|---|---|
| It runs once, correctly | It runs 10,000 times, idempotently | Bad data costs the average org ~$15M/yr (Gartner) |
| It handles the record you tested | It handles the null, the dup, the re-fire | Firmographic data decays ~30%/yr (ZoomInfo) |
| It looks right in the builder | It fails loud, not silent, when it breaks | AI 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.
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.
View as table
| Point | Value |
|---|---|
| M1 | 180 |
| M2 | 620 |
| M3 | 1,150 |
| M4 | 2,040 |
| M5 | 2,610 |
| M6 | 3,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.
- 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
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
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
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
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 |
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.
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