A helper function in my sports SaaS app had a default argument that pointed at the wrong account. Fixing it meant threading the same eight-character override through 27 call sites by hand. I didn’t change the default, even though I could have done it in one line. Here’s why the one-line fix was the dangerous one, and what the tedium of the safe fix was actually telling me.

The function that scoped everything

The app is Classic ASP running server-side JScript on IIS, fifteen years old, and very profitable. Nearly every query on the tournament side passes through one helper that builds the WHERE username = '...' clause:

function GetAccountFilter(pref, div, teamsExclude, options) {
var ops = { sportsHQ: pageHQ, /* ... */ };
if (options) $.extend(true, ops, options);
if (!ops.div) ops.div = ops.sportsHQ.username;
// ...builds the filter from ops.sportsHQ.username
}

Look at the default: sportsHQ: pageHQ. There are two ambient context objects every page reads. pageHQ is the logged-in account, the one in the URL. sportsHQ is the account whose data you’re actually operating on. For fifteen years those were always the same object, so the helper’s default of “scope to the logged-in account unless told otherwise” was simply correct. Every caller relied on it without thinking about it, because there was nothing to think about.

The day they stopped being equal

I was building a feature where an org admin could drill into a child event. Pick a child event from a dropdown, and a helper reassigns the module-level sportsHQ to point at that child while pageHQ stays the parent. The moment that happened, pageHQ.username !== sportsHQ.username, and GetAccountFilter’s default quietly became a lie. Every tournament query that didn’t say otherwise kept scoping to the parent account. Saves succeeded. They just wrote to the wrong row. Nothing threw. You only noticed when the data showed up under the wrong event, or never showed up where you were looking.

So the default was now wrong for the tournament callers. And the obvious fix was right there: change line one to sportsHQ: sportsHQ. One line. Ship it.

Why I didn’t flip the default

Because the default wasn’t wrong for everybody. It was wrong for the tournament callers and still correct for the non-tournament ones, which are the majority and which genuinely want pageHQ. Flipping the default fixes the loud minority by silently breaking the silent majority, and “silent” is the operative word. There are no tests here. Nobody files a bug that says “this report scoped to the right account, as it always has.” They file a bug six weeks later that says “a customer’s data is showing up on the wrong site,” and now you’re bisecting a one-line change you made in a hurry and forgot.

The general rule I keep relearning: when a helper has an implicit “current context” default, the safe migration to a new context is not flipping the default. It’s making the new path explicit at every call site that needs it, and leaving the old default exactly where it was for everyone who was right to depend on it. You take the work onto yourself rather than handing a surprise to every caller you can’t see.

The good news was that the right shape was already there. options gets $.extend-ed over the defaults, so the override is clean and local. No global toggle, no hidden mode flag, no setCurrentAccountForReal(true) somewhere up the stack. Just pass the context you mean:

GetAccountFilter('', '', '', { sportsHQ: sportsHQ })

The grind, and what it was telling me

So I went and made it explicit. The override token, repeated verbatim, landed in Add_Locations_TBD, Save_Competition_Setup, Add_EventFolder, Add_Competition, TeamPools_SortOrder, Delete_TeamPool, Add_Bracket_Post, Edit_Bracket_TypeSize, and on down the list. Twenty-seven occurrences across two commits, every one of them this:

// before
where: GetAccountFilter()
// after
where: GetAccountFilter('', '', '', { sportsHQ: sportsHQ })

Three empty positional arguments whose only job is to get me past pref, div, and teamsExclude so I can reach the options bag at position four. That is not incidental noise. That is the signature telling on itself.

The four-positional-args-to-reach-an-options-bag shape means the function was designed when the common call was “give me the filter with all the defaults,” GetAccountFilter(), no arguments. The options bag got bolted on the end later, after positional one through three already had their meanings locked in. Which means the moment the world changed and the interesting argument became sportsHQ, the interesting argument was the one buried farthest from the call. The signature optimized for the case that stopped being the only case.

If I were writing it today it would take one argument, an options object, and pref and div and teamsExclude would be keys in it like everything else:

GetAccountFilter({ sportsHQ: sportsHQ })
GetAccountFilter({ pref: somePref, div: someDiv })

Now the call site reads as exactly what you’re overriding and nothing else. You never type three empty strings to reach the one thing you care about. And critically, adding a fourth or fifth concern later doesn’t bury anything, because there’s no order to be buried behind. The options-bag-from-the-start signature is the one that ages without forcing a 27-site sweep every time the defaults stop being universal.

The interest on an old default

That’s the real shape of what happened, and it generalizes well past this one app. A default argument is a small loan. You take it out on day one because it’s convenient and it’s correct: “scope to the current account” is exactly right when there’s only one notion of “current account.” It costs nothing while the world that justified it holds.

Then the world changes. You add impersonation, or sub-accounts, or any context switch where the thing-you-are and the thing-you’re-acting-on come apart. The day that happens, the default stops being free. It starts charging interest, and the interest is paid as tedium at the call site: 27 places where you now have to say out loud the thing the default used to say for you. The more places trusted the default, the bigger the loan was, and the bigger the bill.

That tedium is not a sign you did the fix wrong. It’s the sign you’re paying down a real debt instead of rolling it forward. Flipping the default would have been refinancing the loan onto someone else’s account, the silent callers who never asked to co-sign. Threading the explicit context through every site that needs it is just paying what you actually owe, in the currency the loan was always going to be called in.

So when you reach for a default argument that means “the current X,” ask what happens the day there are two X’s and they disagree. If the honest answer is “every caller that took the default is now wrong and I can’t grep my way to confidence,” you’re not adding a convenience. You’re opening a line of credit, and the call sites are where it comes due.