Three client codebases shared one Google Ads manager-account OAuth token. That token, by design, grants access to every account the manager account manages — which is exactly why it’s convenient, and exactly why it was dangerous.
The symptom showed up as polluted state files. One client’s ads-state.json snapshot contained another client’s campaigns and customer IDs. Nothing had been mutated wrongly yet, but the snapshot feeding the weekly health check was reading the wrong account’s data — which means the health check would have analysed one B&B’s spend and attributed the conclusions to another. In a system whose entire value proposition is “each account is independent, never cross-recommend,” that’s not a cosmetic bug.
Why the convenient token is the hazard
The original logic resolved which accounts to query like this: take the site’s configured account IDs if it has any; otherwise fall back to whatever IDs the OAuth token can reach. The fallback is the trap. A shared manager-account token can reach every client’s accounts. So any code path where the site-config IDs were empty, missing, or read before the config loaded would silently expand to the full portfolio. The same token that makes it cheap to manage five accounts from one credential makes it trivial to read — or write to — the wrong one.
This pattern was duplicated across four scripts: the health-check fetcher, the snapshot writer, the portfolio aggregator, and the mutation script. Each had its own copy of the “site-config IDs take precedence, else fall back to OAuth IDs” logic, with one subtly different filter. Four copies of a security-relevant decision is four chances to get it wrong, and four places to fix when you do.
First fix: centralise the decision, abort hard on writes
The first step was collapsing the four copies into one function — siteCustomerIds(site, oauth) — that owns the rule: site-config IDs are authoritative; the OAuth fallback applies only when a site genuinely has no configured accounts. Every script now calls the same function, so account resolution behaves identically whether you’re reading a snapshot or aggregating the portfolio.
Reads and writes needed different treatment. A wrong read produces a confusing report you can catch later. A wrong write changes a paying client’s live campaign. So the mutation script got a hard abort: before it touches anything, it checks that the resolved customer ID is actually in this site’s config. If it isn’t, the script prints the valid IDs and exits non-zero.
The same pass added an ownIds filter to loadAdsState() in render.ts: even if ads-state.json is already contaminated, the data is filtered to this site’s own accounts before it reaches the LLM. Two layers of defence — one at query time, one at render time.
The fallback was still a hole
The architecture looked solid. But there was a detail that left the guard bypassable.
The mutation script’s abort condition required accounts.length > 0 to proceed to the account check. If a site had no configured accounts at all, the condition short-circuited — the guard never ran. At the same time, siteCustomerIds() would kick in its OAuth fallback: the script would resolve to all accounts under the MCC, pick the first one as its target, and continue without a word.
A site-config that was simply missing its accounts field — a misconfiguration that should have been caught immediately — would instead let the read path silently pull data from every client, and let the write path’s guard be bypassed entirely because accounts.length was zero. The problem wasn’t that the guard was written wrong. The problem was that the fallback provided a path that made the guard unreachable.
Replace the fallback with a loud failure
The fix was to remove the fallback. siteCustomerIds() no longer falls back to OAuth IDs — if a site has no configured accounts, the function throws and the script stops. The _oauth parameter stays in the signature for interface compatibility but is never used.
Missing accounts in site-config → function throws → script halts → nothing runs. Both read and write paths now refuse to execute on a misconfigured site. The guard’s logic gap disappears as a side effect: the accounts.length === 0 case never reaches the check because the process has already exited.
The read/write asymmetry from the first fix was deliberate — a silent widening on reads produces bad data you can still catch in review; a silent widening on writes is irreversible and aimed at someone else’s money. What the second fix adds is that the asymmetry shouldn’t exist at all. Silent degradation on the read path is equally unacceptable.
The structural point, not the incident
The cleanup also re-ran the contaminated snapshots so the committed state files matched their own accounts again. That’s the visible part. The durable part is two layers deep.
First, the decision about which accounts a site may touch now lives in exactly one function, not four diverging copies. Second, that function no longer permits silent degradation when configuration is incomplete — it either returns the site’s own account IDs or tells you the config is broken. There is no third path.
The shared manager-account token isn’t going away — it’s what makes running ads for five properties on a small-business budget affordable in the first place. What’s gone is the fallback that converted a missing config field into a silent cross-site operation.