---
title: "The ambient-global bug: when current account is a mutable global your whole app reads"
canonical: https://dxdev.com/blog/ambient-global-current-account-mutable-impersonation/
datePublished: 2026-02-27
---
# The ambient-global bug: when current account is a mutable global your whole app reads

I shipped a feature that let an org admin pick a child event from a dropdown. The moment they picked one, saves started writing to the parent org account instead of the event they were looking at. The save succeeded every time. It just hit the wrong row.

The cause wasn't one bug. It was over a hundred call sites all trusting the same global variable, and I'd just made that global lie.

## Two globals that were always equal

The app is a Classic ASP application running server-side JScript on IIS. Every page reads two ambient globals: `pageHQ`, the logged-in account from the URL, and `sportsHQ`, the account whose data you're actually operating on. For years those two pointed at the same object on every request, so the codebase used them interchangeably. `pageHQ.username` and `sportsHQ.username` were the same string, always, so nobody thought about which one a given query should filter on. They reached for whichever was in scope.

That's fine. A mutable global that a hundred call sites read is genuinely fine, right up until two things that used to be equal stop being equal.

## The feature that broke the equivalence

The feature was Tournament Event Folders. An org or tournament admin runs the parent account, and underneath it there are child events. The new flow let that admin drill into a child event by selecting it from a dropdown, which navigates to `?div=<EVENTUSERNAME>`.

On the server, a helper called `SetDivToHQ()` reads that param and reassigns the global. It pulls `Request.QueryString("div")`, falls back to `?sportsHQ=`, then to `pagePrefs.div`, and if it resolves to a real child node it does `var tryToGetHQ = SportsHQGet(div); if(tryToGetHQ.username) { sportsHQ = tryToGetHQ; }`. That's a wholesale swap of the module-level `sportsHQ` variable, mid-request, based on a query-string param.

After that line runs, `pageHQ` still points at the parent and `sportsHQ` points at the child. For the first time ever, `pageHQ.username !== sportsHQ.username`.

Now every query that had been written as `WHERE username = pageHQ.username`, or that set `DBwhereStrs["username"] = pageHQ.username`, kept targeting the parent. The admin is looking at a child event, editing its brackets, hitting save, and the write lands on the org account. Nothing throws. The save returns success. You find out when the data shows up under the wrong event, or never shows up where you're looking.

There's no exception, no stack trace, no failed assertion. The wrong answer is indistinguishable from the right one at the call site. The only signal is data quietly appearing in the wrong tenant.

## The blast radius

Fixing it was not a one-liner. It was a sweep.

The core save fix was a single commit titled "save bug fix." It touched two tournament AJAX handler files, 91 lines added and 91 deleted, almost pure substitution. The change is mechanical. `dbObj.DBwhereStrs["username"] = pageHQ.username;` becomes `dbObj.DBwhereStrs["username"] = sportsHQ.username;`, repeated across the bracket and tournament save handlers. In the bracket info save handler it also caught INSERTs that were writing `parentNode`, `masterNode`, and `league` as the parent account when they should have been the child.

One file had roughly 80 lines of this same substitution. You can't review that and feel confident. There's no test suite to tell you which `pageHQ.username` was load-bearing for the parent and which was a latent child-event bug. You're reading every one by hand and asking "is this site scoping to the account I'm logged in as, or the account I'm operating on?" That question had no answer for as long as the codebase had existed, because it didn't matter. Now it's the whole game.

## The default that was correct until it wasn't

There's a helper, `GetAccountFilter(pref, div, teamsExclude, options)`, that builds the `WHERE username = '...'` clause for nearly every tournament query. Its internal default is `sportsHQ: pageHQ`. Scope to the logged-in account unless told otherwise.

That default was correct for as long as the codebase had existed, and wrong the instant an org admin could be editing a child. The tempting fix is to flip the default. Don't. Flipping it would break every non-tournament caller that genuinely wants `pageHQ`, the silent majority that relied on the old behavior. The right fix is to make the new path explicit at every call site that needs it:

```
GetAccountFilter()  ->  GetAccountFilter('','','',{sportsHQ:sportsHQ})
```

That override appears 27 times across the bracket-manager event-filter commit, in functions like `Add_Locations_TBD`, `Save_Competition_Setup`, `Add_EventFolder`, `Add_Competition`, `TeamPools_SortOrder`, `Delete_TeamPool`, `Add_Bracket_Post`, `Edit_Bracket_TypeSize`, and more. Four positional args, three of them empty strings, just to reach the options bag. The call-site noise is itself a design critique: a helper whose whole job is "current context" should have taken an options object from the start, not buried it behind three positional args nobody fills in.

The one good thing was that the override shape already existed. Options get `$.extend`-ed over the defaults, so the override is clean and local. No global mode flag, no hidden toggle. The correct path was always reachable. It just wasn't the default, and the default is what a hundred call sites silently inherited.

## It wasn't only the database filter

Fixing the SQL filter closed the blast radius on the server side. But account context divergence doesn't stay in one layer.

The client-side "same sport" filter compared against a global `sport` variable that the server always set from `pageHQ`. When an admin selected a child event of a different sport, the filter compared against the org's sport, not the event's. The fix introduced a new bridge variable carrying the selected event's `{username, sport, teamname}` to the browser, with a comment in the server code spelling out that you use that variable's `.sport` anywhere you need the correct sport client-side, instead of the global `sport` that always reflects `pageHQ`.

Read that comment back. It's the same bug in a different layer. The app now has `pageHQ`, `sportsHQ`, and the new bridge global all coexisting, and every new line of code has to know which one is correct for its purpose. That ambiguity is the bug class. Hand-rolled hydration, `JSON.stringify` into an inline script tag, works fine and has zero dependencies, but it has no concept of "the one correct context object." Every new context you add is another global the client has to disambiguate.

## The takeaway

The day you introduce any form of impersonation, sub-account, or context switching, current user and current tenant should be passed as an explicit parameter, not read from an ambient global. Not refactored later, that day.

The reasoning is simple and it generalizes past Classic ASP. A mutable global that many call sites read is fine right up until two things that used to be equal stop being equal. The moment they diverge, every one of those call sites becomes a latent bug, and there is no grep that gives you confidence about which ones, because the variable name is identical whether the site is correct or broken. The compiler can't help you. With no test suite, nothing can help you except reading every line and answering the question the old code never had to ask: is this scoping to who I am, or to what I'm operating on?

If you have a "current account," "current user," or "current tenant" global today and you've never needed those two ideas to differ, you have this bug waiting. It costs nothing while they're always equal. It costs you a 91-line-by-91-line hand audit the first time they aren't.

## Related

- [The Ambient-Global Bug: When "Current Account" Is a Mutable Global Your Whole App Reads](the-ambient-global-bug-mutable-current-account): the canonical framing of this bug class
- [The ambient-global bug: when "current account" is a mutable global 100 call sites read](ambient-global-current-account-context-switch): the context-switch lens on the same incident
- [GetAccountFilter and the Case for an Explicit Context You Can Override](explicit-context-object-you-can-override-default-arg): the explicit-context pattern that fixes the root cause
- [Hand-rolled hydration: passing server state to the browser with no framework](globalvars-injection-server-state-to-client-no-framework): the client-side layer where context ambiguity compounds
- [A 47,490-line JavaScript file and what it tells you about a profitable legacy app](47000-line-js-file-profitable-legacy-app): the codebase shape that makes this bug class hard to grep your way out of
- [I overwrote a live production mailer because I assumed an ID was free](i-overwrote-a-live-mailer-because-i-assumed-an-id-was-free): another silent overwrite caused by an ambient assumption about shared state
