We shipped a feature that let an admin pick a child event from a dropdown, and the moment they picked one, every save started writing to the wrong account. No error. No exception. The save succeeded. It just landed on the parent org’s row instead of the event the user was staring at. The cause was every query in the file trusting a global variable that we had just made lie.

Here’s the setup, because the shape matters more than the specific app. The app is an older web application running server-side JScript on IIS. Every page reads two ambient globals. pageHQ is the logged-in account, the one in the URL. sportsHQ is the account whose data you’re actually operating on. For years those two were always the same object. So the codebase used them interchangeably, thousands of times, and nobody ever thought about which one was correct because there was never a case where they differed.

Then we built event folders. An org admin can now drill into a child event with ?div=<EVENTUSERNAME>. A helper called SetDivToHQ() reaches into the module scope and reassigns the global sportsHQ to point at the child event, mid-request, based on that query-string param. pageHQ stays the parent. For the first time in the app’s history, pageHQ.username !== sportsHQ.username.

That single inequality turned roughly a hundred call sites into latent bugs simultaneously.

The blast radius

Every query that built its scope from the logged-in account was now wrong. The pattern was everywhere:

dbObj.DBwhereStrs["username"] = pageHQ.username;

That line is correct when pageHQ and sportsHQ are the same. It silently targets the parent the instant they diverge. The save you fire against the child event writes a WHERE username = '<parent>' clause, finds the parent’s row, updates it, and reports success. Nothing throws, because nothing is wrong as far as SQL or the runtime is concerned. You only notice when the data shows up under the wrong event, or doesn’t show up where you’re looking. That is the worst kind of bug: a correct-looking operation producing a confident, wrong result.

The fix for the save path was almost insulting in how mechanical it was. The commit was a pure swap of one global for the other:

dbObj.DBwhereStrs["username"] = pageHQ.username;
->
dbObj.DBwhereStrs["username"] = sportsHQ.username;

The bulk of it lived in the AJAX handler, where 80 lines flipped pageHQ.username to sportsHQ.username. Those 80 were scattered across dozens of handlers, on and on, because every one of them had at some point reached for the logged-in account to scope a query. In the save handler the INSERTs also wrote parentNode, masterNode, and league from the wrong account, so new rows were being stamped with the parent’s identity. The same swap hit a second file for another 11 lines across the handlers. The whole commit was 91 insertions against 91 deletions, two files, give or take 180 lines. It was pure substitution. There was no clever logic to write. There was just a global we had to stop trusting in 90-odd places, by hand, hoping we caught all of them.

You cannot grep your way to confidence here, and that’s the lesson. pageHQ.username is not wrong. It’s the correct expression in the majority of the codebase that scopes to the logged-in account. So a global find-and-replace would have broken more than it fixed. We had to read each call site and decide which account it meant. There is no compiler, no type, no test that distinguishes “the account in the URL” from “the account being edited” when both are just .username off an ambient object.

The mutation itself is the landmine

Step back to SetDivToHQ(). It reads Request.QueryString("div"), falls back to a couple of other sources, and if the value resolves to a real child node it does the equivalent of this:

sportsHQ = SportsHQGet(theChildNode);

A wholesale reassignment of a module-level global, mid-request, driven by a URL parameter. It is incredibly convenient. It is also the reason the correctness of every downstream line now depends on when in the request that mutation ran relative to each read. Any code path that read sportsHQ before the swap, or that cached a value derived from it, or that ran in a context where the swap never fired, is now subtly out of sync with the rest of the request. A mutable global is a shared clock that you’ve made tick at an unpredictable moment.

This is the part we’d tattoo on a junior dev: a global variable that a hundred sites read is completely fine right up until two things that used to be equal stop being equal. The day you add impersonation, a sub-account, a “view as,” an org admin drilling into a child, a tenant switcher, any of it, every one of those read sites becomes a question you have to answer one at a time. The convenience you banked comes due all at once, with interest, in a single afternoon of swapping globals and praying you found them all.

What we’d build instead

The fix is not “swap the global.” That’s the patch we shipped because it was the smallest safe change to a revenue app we couldn’t rewrite. The structural fix is that current user and current tenant should be passed as explicit parameters, not read from ambient state, the day you introduce any form of context switching.

There’s a hint of the right shape already in the codebase. The account-scoping helper defaults its scope to pageHQ but lets you override it through an options bag that gets $.extend-ed over the defaults. So the other fix was threading {sportsHQ: sportsHQ} through about 27 call sites:

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

Note the three empty positional args just to reach the options bag. That call-site noise is itself a design critique. A function whose job is “scope this query to a context” should have taken the context as its first and only argument from the start. But the important part is that the override is local and explicit. No global toggle, no hidden mode flag. The safest migration to a new context is making the new path explicit at every site, even when it’s tedious, because the tedium is exactly the visibility you need. Flipping a default that the silent majority relies on is not safe.

If we were doing this clean, there’d be one context object, passed down, never reached for ambiently. Every function that scopes data would take it as a parameter. An impersonation feature is then a change to what you pass, not a landmine planted under code written by someone who had no idea this day was coming.

The hand audit

The core save fix was a single commit touching two AJAX handler files, 91 lines added and 91 deleted, almost pure substitution of one account global for another. There was no stack trace and no grep that finds what is broken. It came down to reading every line by hand and asking whether each one scoped to the account that is logged in or the account being operated on.

Takeaway

Ambient “current X” globals are correct only while there is exactly one X. Before any context switching enters the picture, such as an admin drilling into a child account or a “view as another user” feature, pass the current user and current tenant explicitly.