---
title: "The Ambient-Global Bug: When \"Current Account\" Is a Mutable Global Your Whole App Reads"
canonical: https://dxdev.com/blog/the-ambient-global-bug-mutable-current-account/
datePublished: 2026-06-03
---
I shipped a feature that let an org admin pick a child event from a dropdown. The moment they did, saves started writing to the parent org account instead of the event they were looking at. Nothing threw. The save succeeded, just against the wrong row.

The cause wasn't one bug. It was eighty call sites, correct for years, all trusting one global variable that I'd just made lie.

This is an old ASP Classic SaaS I run for sports leagues and tournaments. It runs server-side JScript on IIS, years deep. Two globals live on basically every page: `pageAccount`, the account in the URL and the one you logged into, and `activeAccount`, the account whose data you're actually operating on. Over the years those two had always pointed at the same object. So the codebase used them interchangeably. `pageAccount.username` here, `activeAccount.username` there, didn't matter, they were equal, and code that's equal stays uncorrected because nobody ever sees it fail.

Then I built nested event folders.

## How two equal things stopped being equal

The new feature gives an org or tournament admin a dropdown to drill into a child event. The selected event rides in the URL as `?div=<EVENTUSERNAME>`. On the server, a helper called `setActiveAccount()` reads that param and reassigns the global:

```jscript
// helpers/account-helpers.asp, function setActiveAccount(ops)
// div = Request.QueryString("div"), falls back to ?activeAccount=, then sessionPrefs.div
if (!div || (div == activeAccount.username)) return;
if (!childAccounts[div]) return;
var tryToGet = accountLookup(div);
if (tryToGet.username) {
    activeAccount = tryToGet;   // wholesale swap of the module-level global
}
```

That one assignment is the whole story. `activeAccount` now points at the child event. `pageAccount` still points at the parent org. For the first time in the app's life, `pageAccount.username !== activeAccount.username`.

Every query in the tournament code that filtered `WHERE username = pageAccount.username`, or set `whereFilters["username"] = pageAccount.username`, was now silently targeting the parent. The reads still returned rows. The saves still committed. They were just aimed one level up from where the admin was looking. You don't get an error. You get data that shows up under the wrong event, or never shows up where you're staring.

That is the worst shape a bug can take. A loud failure gets fixed in five minutes. A save that writes to the wrong tenant and returns success gets fixed after a customer notices their bracket data vanished.

## The fix was mostly find-and-replace

The core save fix is almost insulting in how mechanical it is. In the main tournament handler it's the same substitution, eighty times:

```jscript
// before
dbObj.whereFilters["username"] = pageAccount.username;
// after
dbObj.whereFilters["username"] = activeAccount.username;
```

The 80 lines cluster in one method, `saveRecord` (14 of the hunks), and there it wasn't just WHERE clauses either. The INSERTs were writing `ownerAccount`, `rootAccount`, and `groupKey` set to the wrong account, so new rows were being born under the parent too. The same substitution runs through a companion file, across `saveField`, `resetAll`, `computeStandings`, and `listSettings`.

That commit touched those two files: 91 lines added, 91 deleted, nearly pure substitution. There's no clever logic in it. The hard part wasn't writing the fix, it was knowing that `pageAccount` was wrong in exactly these places and right everywhere else. `pageAccount.username` appears roughly 580 times across 168 files in this app. You cannot grep your way to confidence, because it's correct in the hundreds of non-tournament call sites and wrong in these eighty, and they look identical.

## The other half: an override threaded through 27 call sites

Most tournament queries don't hand-build their WHERE clause. They go through a helper:

```jscript
// helpers/account-helpers.asp
function buildAccountFilter(pref, div, teamsExclude, options) {
    var ops = { activeAccount: pageAccount, ... };   // default: scope to the logged-in account
    if (options) $.extend(true, ops, options);
    if (!ops.div) ops.div = ops.activeAccount.username;
    ...
}
```

Look at the default. `activeAccount: pageAccount`. "Scope to the account in the URL unless told otherwise." That default had been correct for years and was wrong the instant an org admin could be editing a child event.

The tempting fix is to flip the default to `activeAccount: activeAccount`. Don't. That breaks the silent majority, all the non-tournament callers that genuinely want `pageAccount` and never passed an override because they never had to. Flipping a default that's been in place for years is how you turn one bug into fifty.

So instead the tournament callers got made explicit, by hand, one at a time:

```jscript
// before
where: buildAccountFilter()
// after
where: buildAccountFilter('','','',{activeAccount:activeAccount})
```

Twenty-seven times, all in one commit. `addLocations`, `saveScheduleSetup`, `addSubEvent`, `addCompetition`, `teamPoolsSortOrder`, `deleteTeamPool`, `addEntryPost`, `editEntryTypeSize`, and on down the main handler (18 of them), with the rest spread across four companion files.

Notice the shape of that call: `('','','',{activeAccount:activeAccount})`. Four positional arguments, three of them empty strings, just to reach the options bag at the end. The call-site noise is its own design critique. If `buildAccountFilter` had been `buildAccountFilter(options)` from the start, the override would read `{activeAccount: activeAccount}` and that's it. Tedium at the call site is the interest you pay on an old signature.

One thing the helper got right: because options are `$.extend`-ed over the defaults, the override is clean and local. No global toggle, no hidden mode flag, no "impersonation mode" boolean sitting in session state waiting to leak across requests. The right shape was already in the helper. It just wasn't the default path, and getting onto it cost 27 edits.

The real hazard isn't "I typed the wrong variable." It's that `setActiveAccount()` reassigns a module-level global mid-request based on a query-string param, and the correctness of every line downstream now depends on when that mutation happened relative to the read. The variable name tells you nothing. In a 12,000-line handler, call order is not something you hold in your head.

A mutable global that 580 call sites read is fine right up until two things that used to be equal stop being equal. Then every one of those sites is suspect at once, and no tool tells you which subset is now wrong, because the wrong ones and the right ones are byte-for-byte identical. You audit them by hand, fueled by knowledge that lives nowhere in the code. The override that took me 27 hand-edits to retrofit would have been free if "which account" had always been a parameter instead of something in the air.

## Related

- [The ambient-global bug: when "current account" is a mutable global 100 call sites read](ambient-global-current-account-context-switch): the same bug class from a different angle, focusing on context-switch timing
- [The ambient-global bug: when current account is a mutable global your whole app reads](ambient-global-current-account-mutable-impersonation): deep-dive companion on the impersonation-mode variant of the same pattern
- [The account filter helper and the case for an explicit context you can override](explicit-context-object-you-can-override-default-arg): the helper refactor that makes the 27-override fix unnecessary going forward
- [A 47,490-line JavaScript file and what it tells you about a profitable legacy app](47000-line-js-file-profitable-legacy-app): the broader codebase context where ambient globals are structural, not accidental
- [The Duplicated-Derivation Bug: One Fragile Expression in Four Queries](duplicated-derivation-is-a-correctness-hazard): another case where a shared implicit assumption propagated across many call sites before breaking
