I thought I had a settings bug. What I actually had was three different kinds of state pretending to be one boolean.
While building a Chrome Manifest V3 email-tracker blocker, I expected a simple flow: you flip Gmail on in the settings, and the extension starts working in Gmail. That was the theory, anyway.
The problem showed up when I was testing on a second Chrome profile. I'd enabled Gmail on my main profile, and Chrome Sync helpfully carried that preference over to the other one. But the optional permission for mail.google.com didn't come along — host grants live in the local profile and never sync. Profile number two now believed Gmail was enabled while lacking the host grant needed to inject the inbox content script or inspect its DOM. Depending on how you write your code, that's either a silent no-op or an extension quietly behaving as if access exists when it does not. Neither is great.
Once I stopped and wrote it down, the picture got clearer. There are three separate things here: the inbox the user wants enabled, the host access Chrome has actually granted in this profile, and the dynamic DNR rules that are currently installed. Collapsing them into one flag is convenient. It's also wrong.
The extension declares each webmail origin under optionalhostpermissions. Every inbox gets activated on its own, and Chrome only asks the user for access when they turn that particular integration on.
Here's the thing I had to internalize: declaring an optional origin means nothing by itself. Until the live grant exists, the extension has no business registering a content script for that inbox, poking at its DOM, or — by its own scoping policy — activating client-scoped blocking rules for it.
Why bother with per-inbox prompts at all? Mostly trust. A tracker blocker that asks for all your webmail up front looks exactly like the thing it's supposed to protect you from. Asking for Gmail when you enable Gmail — and nothing more — is an easy story to tell users, and it doesn't hurt during store review either. Chrome's docs recommend optional permissions for exactly this kind of informed control. The docs don't mention the hard part, though: making the runtime actually preserve that choice through permission revocations, browser sync, and service worker restarts. That's on you.
The extension ships two packaged DNR rulesets, and both stay disabled. The background service worker loads the high-confidence pixel rules from rules/pixels.json as a read-only template and clones only those into extension-owned, client-scoped dynamic rules. Recognized tracking links are handled by the content script rather than navigation DNR.
Before generating anything, the extension asks chrome.permissions.contains() about the origin. A synced setting is intent. It is not evidence of a current host grant, so the extension does not activate that client.
The generated blocking rules list only the enabled-and-granted webmail origins in initiatorDomains. If Gmail is the only authorized inbox, a request elsewhere on the web doesn't get blocked just because it happens to match a tracker pattern. The same transformation also emits higher-priority allow rules for tracker domains the user has explicitly whitelisted.
My first instinct was incremental: checkbox on, add rules; checkbox off, remove them. It works right up until reality intervenes. A permission gets revoked from Chrome's own extension settings page. Settings arrive via sync. The service worker dies between two state changes. Several permission and settings events land on top of each other. An update leaves stale dynamic rules behind from the previous version. I hit most of these within a week of testing.
So the extension doesn't track transitions anymore. On every relevant event it rebuilds the entire desired rule set from current state: disable the packaged static rulesets, read the extension-owned dynamic rules, check the current optional host grants, treat anything ungranted as disabled, build the full desired set of block and allow rules, compare normalized fingerprints, and replace the owned rules only if something actually changed.
Run it twice with the same inputs and you get the same rules and no write. That idempotence is what makes the whole thing debuggable. Reserved rule-ID ranges keep this component from stepping on rules it doesn't own.
If the permissions API is unavailable, every client is treated as inactive. If an individual permission check throws, that client is treated as inactive. If the packaged template can't be loaded while old extension-owned rules are still installed, those rules get removed before the error is reported.
Yes, that can temporarily reduce protection. I went back and forth on this while testing — an argument can be made for keeping the last known-good rules around. But keeping behavior alive outside the scope the user explicitly granted felt worse than a gap. A tracker blocker that blocks things beyond that scope is a different kind of broken, and a scarier one.
Settings and permissions can change nearly simultaneously. A user enables an inbox, answers the Chrome prompt, edits the allow list, then disables the inbox again — all inside a few seconds. If each event kicks off its own independent reconciliation, an older snapshot can finish after a newer one and win.
So reconciliations go through a queue. Each snapshot waits for the previous operation to settle. A failed operation doesn't poison the queue or stop later state from applying.
Small detail. But in an MV3 service worker — event-driven, no permanent process — it's the difference between "eventually correct" and "correct until you click fast."
