---
title: "A Client Tree-Walk That Was Really a Server Business Rule"
canonical: https://dxdev.com/blog/client-tree-walk-to-server-business-rule-delete-gate/
datePublished: 2026-02-26
---
There is a special kind of bad code that runs perfectly, ships for years, and is wrong the entire time. Not crashing-wrong, not throwing-an-error-wrong, just quietly answering a different question than the one anyone meant to ask. I found one of these last week in a "can you delete your own account?" gate. The thing that finally fixed it was deleting all the cleverness and asking the server.

Here's the setup. The product is a sports-org SaaS where a customer's site is a three-level org tree: the site sits at the top, leagues or seasons hang off it, and individual teams 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 nukes a year of rosters and schedules. So there's a gate. Past a certain size, the delete button stops being a delete button and starts being a "please contact support" message.

Reasonable policy. The implementation is where it went sideways.

## The browser was doing the math

The old gate lived in the browser. To decide whether the customer was allowed to self-delete, the client first had to know how many teams the site had. And to know that, it walked the org tree. In JavaScript. By hand.

It looked roughly like this once you stripped the noise out:

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

That `teamCount` did not arrive from anywhere sensible. It was computed on the page by a thirty-five-line nested loop that walked an org-graph payload: iterate `siteNode.children`, then each child's children, then each of those, incrementing a counter every time it hit a node whose type was `team`. Three loops deep, one for each level of the tree, each level hardcoded.

The browser receives a serialized org graph, then re-derives a fact about that graph that the server already knows authoritatively, by manually traversing it at exactly three levels of depth. If the data model ever grew a fourth level, the count would be silently wrong and nothing would tell you. It would start under-counting teams and letting people delete sites that should have been gated. No error, no log line, just a slowly drifting wrong answer.

And the count was only ever used to make one binary decision: show the delete button, or show the contact-support message. We shipped a thirty-five-line client-side graph traversal to produce a single boolean.

## The other half of the condition was the actual bug

Look at that condition again: `teamCount > 20 || packageValue > 100`.

The second clause is the one that bit us. `packageValue` was a config number representing 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 me into this code was a complaint that trial accounts could not delete their own sites. For this policy, an unpaid evaluation site should not have been treated like a high-value customer account. Forcing a trial user to email support to delete an empty evaluation site created friction without matching the policy's actual 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 was reading "what plan is this nominally" when the business meant "how much has this customer actually invested with us." Package value and money received are two different numbers, and the code had quietly encoded the wrong one. The tree-walk wasn't the only problem. It was sitting right next to a definition mismatch that had probably been mis-gating trials the whole time.

That's the part worth slowing down on. The bug was not a typo or an off-by-one. The bug was that the code computed *a* number, that number looked plausible, and nobody noticed it answered a subtly different question than the policy intended.

## The rewrite: two authoritative numbers

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

```js
function DeleteLimitInit() {
  var numTeams   = GetOrgInfo("numTeams");
  var moneySpent = /* SQL.get summing paid transaction totals */;

  globalVars.contactToDelete =
    (numTeams > NUM_TEAMS_FORCED_CONTACT ||
     moneySpent > MONEY_SPENT_FORCED_CONTACT) ? 1 : 0;
}
```

For the team count, the server already maintains org info. `GetOrgInfo("numTeams")` returns the count straight from the source of truth. No traversal, no depth assumption, no fourth-level blind spot. If the tree grows a level, the server's count grows with it, because the server isn't guessing from a serialized snapshot, it owns the data.

For "value," I 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, sails under the threshold, and can delete its own site like it should always have been able to.

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

```
NUM_TEAMS_FORCED_CONTACT = 20
MONEY_SPENT_FORCED_CONTACT = 300
```

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

```js
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 receives one flag and acts on it 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.

## Related

- [GetAccountFilter and the Case for an Explicit Context You Can Override](explicit-context-object-you-can-override-default-arg): a related pattern for replacing implicit logic with explicit, testable inputs
- [A Trusted Gateway POST Is Still a Public Endpoint](trusted-gateway-post-is-still-a-public-endpoint): the flip side, server-side rules still need boundary validation
