A vendor on two purposes does not run on a partial grant
A consent decision records a category, but the party receiving the request is a vendor, and one vendor often processes for several purposes. When a visitor grants one of those purposes and refuses the other, a category-shaped gate has no honest answer available: it either sends the request and trusts the vendor to honour the refusal on its own side, or it keeps the vendor off the page completely. We build consent tooling that does the second, and the reasoning is worth writing down, because the first option looks so much more reasonable.
The constraint is that we cannot verify the first option. Once the request leaves the browser, whether the vendor respects the half of the decision we did not enforce is a claim we can repeat but not check. That is the whole problem in one sentence, and it is why the rule in consentcore (our open implementation of consent as state, at https://github.com/shipmindlabs/consentcore) is deliberately strict.
Two different questions#
A banner asks about categories. consentcore has four, necessary plus the three a visitor can actually move: preferences, statistics, marketing. A Decision records granted as a list of those categories, alongside at, noticeVersion, noticeHash and method. That is the shape an audit asks about, and it is the right shape for the answer.
It is the wrong shape for the request. "statistics" does not say which vendor gets the call, what cookies they set, or where the data ends up. The list that does say all of that is normally prose, written three times, once in the banner, once in the cookie policy, once in a record of processing, and the three drift apart within a quarter. So it gets declared once, as data.
One list, checked once#
const declaration = declare({
purposes: [session, audience, retargeting],
vendors: [
{
id: "acme-analytics",
name: "Acme Analytics",
purposes: ["audience"],
policy: "https://acme.example/privacy",
cookies: ["_acme"],
transfers: "US",
},
{ id: "adnet", name: "AdNet", purposes: ["audience", "retargeting"] },
{ id: "first-party", name: "This site", purposes: ["session"] },
],
});declare checks the list before it hands it back, and every failure it catches is one a reviewer would otherwise find in the published notice. A purpose with no id, or an id declared twice so one of the two could never be shown. A category that is not one of CATEGORIES. A purpose missing its name or description, since informed consent needs something to be informed by. On the vendor side: no id, no name, declared twice, declaring no purpose at all, or naming a purpose nobody declared. Each one throws InvalidDeclaration with the specific reason.
What comes back is plain data in fresh arrays. No class, no hidden index, nothing to initialise: it can be JSON, committed next to the code, diffed in review and published beside the notice. The lookups are deliberately unforgiving in the same direction. purpose and vendor throw InvalidDeclaration on an unknown id rather than returning undefined, because a mistyped id that quietly matches nobody reads as a refusal in one direction and as an empty grant in the other, and neither is what the caller wrote.
adnet in that declaration is the interesting row. One vendor, two purposes, two categories.
Permitted only while every purpose is granted#
allowsPurpose answers through the purpose's category. It looks the purpose up and asks allows. allowsVendor does something stricter: it maps purposesOf over the vendor and requires every resulting category to be allowed.
test("before any decision only necessary purposes are allowed", () => {
const state = unset();
assert.equal(allowsPurpose(declaration, state, "session"), true);
assert.equal(allowsPurpose(declaration, state, "audience"), false);
assert.deepEqual([...runnable(declaration, state)], ["first-party"]);
});
// Half a grant is not half a vendor: the request either goes or it does not.
test("a vendor whose purposes are only partly granted does not run", () => {
const state = grant(declaration, ["audience"], options);
assert.equal(allowsVendor(declaration, state, "acme-analytics"), true);
assert.equal(allowsVendor(declaration, state, "adnet"), false);
assert.deepEqual([...runnable(declaration, state)], ["acme-analytics", "first-party"]);
});acme-analytics declares one purpose and that purpose was granted, so it runs. adnet declares two and got one, so it does not run at all, not for retargeting, which was refused, and not for audience counting, which was granted. runnable lists the permitted vendors in declaration order, and adnet is simply absent from it.
The alternative is to load adnet for the audience grant and rely on the vendor to withhold the retargeting half. That is sending the request and leaving the vendor to honour the rest, which is the visitor's decision handed to the party it was made about. It also inverts who carries the risk: we made the integration decision, the visitor made the consent decision, and under the permissive reading the vendor adjudicates between them with no obligation to tell either of us how it went.
Granting and refusing are the same call, which matters for the same reason. grant and refuse take the same arguments in the same order, and a test asserts grant.length === refuse.length, because withdrawal cannot be harder than consent if it is literally the same signature. Refusing either of adnet's two purposes takes it out of runnable again.
The tag registry reads the same declaration#
Queued work is a one-shot side effect: it runs once and is over. A third-party tag is not. It keeps running, and consent can move under it after it started, so tags are a registry rather than a queue, and a tag can name a vendor instead of a category:
const tags = new Tags(state, { declaration });
tags.register({ id: "ads", vendor: "adnet", src: "https://cdn.example/ad.js" });
tags.update(grant(declaration, ["audience"], options)); // held: adnet also needs retargeting
tags.update(grant(declaration, ["retargeting"], options, state)); // now it goes on the page
tags.update(refuse(declaration, ["audience"], options, state)); // and now it comes off againSuch a tag is on the page only while allowsVendor is true. The element carries data-consent-vendor rather than data-consent-purpose, and tags.history records the vendor id, because the vendor is what the tag was gated on, so a page can be inspected for what is running and under which answer. On refusal the element is removed rather than left in place with type="text/plain", which would keep a refused vendor one line of unrelated code away from running. cleanup is where the cookie it already set gets dealt with, since taking an element off the page does not undo what it did while it was there.
Two failures are refused up front instead of being swallowed. register calls vendor on a VendorTag, so a vendor the declaration does not have is rejected at registration rather than held forever, because a tag waiting on a typo looks exactly like a tag waiting on a visitor, and the two need to be distinguishable. And a VendorTag registered where no declaration was supplied throws InvalidDeclaration from the private #declared accessor, because a gate with nothing to read is not a gate.
The notice and the record read it too#
The third consumer of the same list is the record. declarationHash sorts purposes and vendors by id, sorts each vendor's purposes and cookies, writes policy and transfers as empty strings when absent, and feeds the resulting JSON to hashNotice. Because it is order-insensitive in exactly those places, it can serve as noticeHash: moving a line in the declaration file re-asks nobody, while adding a vendor or changing the cookies one sets produces a different hash, and a stored decision taken against the old one is not carried forward.
That is the payoff of declaring once. The banner, the gate, the tag registry and the stored proof all read the same checked data, so there is no version of the system where the notice lists a vendor the gate has never heard of.
What it costs to run#
The cost is real and it lands in one place: a dual-purpose vendor stays off even for the purpose that was granted. Audience numbers from visitors who accepted statistics and refused marketing are not collected, because the vendor that would collect them also wanted retargeting. Someone will ask for that data back.
The second cost is maintenance. The declaration becomes an artefact with an owner. When a vendor changes the cookies it sets, the row changes, the hash changes, and visitors are asked again, which is the correct behaviour and still a cost to schedule around.
The remedy is not to loosen the rule. It is to make the declaration match the integration: declare the vendor under the purposes that integration actually needs, or use separate vendors for separate purposes and let each be gated on its own. Both of those are conversations that can be had and answered. "We sent the request and assumed it was handled" is not.
Across the payment, KYC and compliance tooling our team has built, the part that holds up under review has rarely been the control itself. It is the record of what the control did, and the absence of a path where the record and the behaviour can disagree. Vendor-level gating is that rule applied to consent: the strict version is probably the only one we can demonstrate, so it is the one in the library.