A nested loop thirty-five lines long ran in the browser to produce one boolean: show the delete button, or show a “please contact support” message. We found it last week in a “can you delete your own account?” gate. It ran fine and shipped for years, and it answered a different question than the one anyone meant to ask. It never crashed or threw. What fixed it was deleting the cleverness and asking the server.

The setup

The product is a live SaaS platform where a customer’s site is a three-level org tree. The site sits at the top, organizations or seasons hang off it, and individual unit nodes hang off those. A customer can self-serve-delete their whole site from the admin area, but not unconditionally. If the site is “big enough” or “valuable enough,” we want a human in the loop before someone deletes a year of schedules and records. So past a certain size, the delete button becomes a “please contact support” message.

That policy is reasonable. The implementation is where it went sideways.

The browser was doing the math

To decide whether the customer could self-delete, the client first had to know how many unit nodes the site had. So it walked the org tree in JavaScript, by hand. Stripped of noise, the gate looked like this:

if (deletePageConfig.unitCount > 20 || deletePageConfig.packageValue > 100) {
// "Please contact support to delete your site."
}

unitCount did not arrive from anywhere sensible. The page computed it with a nested loop over a serialized org-graph payload: iterate siteNode.children, then each child’s children, then each of those, incrementing a counter on every node whose type was unit. Three loops deep, one per level, each level hardcoded.

So the browser received a serialized graph and re-derived a fact the server already knows authoritatively, by traversing exactly three levels. If the data model ever grew a fourth level, the count would be silently wrong. It would under-count unit nodes and let people delete sites that should have been gated. No error, no log line, just a slowly drifting wrong answer.

The other half of the condition was the bug

Look at the condition again: unitCount > 20 || packageValue > 100.

The second clause is the one that bit us. packageValue was a config number for the dollar value of the customer’s plan tier. The intent was “valuable customers shouldn’t be able to one-click delete.” Fine in spirit. But package value is the price of the plan they’re on, not money that ever changed hands.

The ticket that sent us into this code was a complaint that trial accounts could not delete their own sites. An unpaid evaluation site should not be treated like a high-value customer account, and making a trial user email support to delete an empty site created friction that had nothing to do with the policy’s purpose.

The reason they were blocked: their package tier had a value above the > 100 threshold, even though they had paid nothing and committed to nothing. The gate read what plan the account nominally was, when the business meant how much the customer had actually invested with us. Those are two different numbers, and the code had encoded the wrong one. Sitting right next to the tree-walk was a definition mismatch that had probably been mis-gating trials the whole time.

That is the part worth slowing down on. The code computed a number, the number looked plausible, and nobody noticed it answered a subtly different question than the policy intended. A typo or an off-by-one would have been easier to catch.

The rewrite: two server-owned numbers

The fix was to stop deriving anything on the client and ask the server two direct questions.

function DeleteLimitInit() {
var numUnits = GetOrgInfo("numUnits");
var moneySpent = /* SQL.get summing paid transaction totals */;
globalVars.contactToDelete =
(numUnits > NUM_UNITS_FORCED_CONTACT ||
moneySpent > MONEY_SPENT_FORCED_CONTACT) ? 1 : 0;
}

For the unit count, the server already maintains org info, and GetOrgInfo("numUnits") returns the count straight from the source of truth. There is no traversal and no depth assumption, so a fourth level can’t blind it. If the tree grows a level, the server’s count grows with it, because the server owns the data and does not guess from a serialized snapshot.

For “value,” we threw out package value entirely and replaced it with money actually received: a direct query summing the total of transactions where the payment-received flag is set. That is the number the business meant all along. A trial that has paid zero dollars sums to zero, sits under the threshold, and can delete its own site the way it always should have been able to.

Two named thresholds, both readable, both on the server:

NUM_UNITS_FORCED_CONTACT = 20
MONEY_SPENT_FORCED_CONTACT = 300

The client got much dumber, which was the goal. It now receives a single flag, contactToDelete, and the confirm path collapsed from a typeof-guarded multi-field check into:

function ConfirmDelete() {
if (globalVars.contactToDelete) {
showAlert("Please contact support to delete your site.");
return false;
}
// proceed with delete
}

That is the entire decision surface now. The browser acts on one flag without re-deriving anything. Simplifying the code and fixing the bug turned out to be the same action: replacing client-side reconstruction with two server-owned inputs made the policy explicit and testable.

What to check on a gate like this

When a rule blocks a customer action, check that each input measures what the business meant, such as money received instead of plan price. Check that counts come from the system that owns the data, since a copy re-derived in the browser can drift without anyone seeing it. A rule built on a plausible but wrong number can run for years without a single error, so ask the people who own the rule which number it actually uses.