Consent is a log bound to the notice it answered
A consent banner that flips a boolean can tell you what a visitor chose. It cannot tell you when they chose it, which text they were answering, or what had already run before they answered at all. Consent has to be freely given, specific, informed, as easy to withdraw as to give, and demonstrable. The first four are mostly interface work. The last one is a data structure, and that is the one products get wrong.
We keep meeting this problem from the compliance side rather than the marketing side. Our team has built KYC and KYB verification flows, moderation tooling that compliance staff use in real time, and legally binding e-signature workflows, systems where the question that matters is not "what does the record say now" but "what can you show later to somebody who was not in the room". Consent is the same class of problem wearing a friendlier hat, so we wrote the mechanics down as a small library, consentcore. This is the reasoning behind it.
A boolean answers one question out of four#
marketing = true is the compressed form of four separate facts: somebody decided, at a particular moment, against a particular notice, granting a particular set of purposes. Three of those fields are thrown away at write time, and the interface never notices, because the interface only needs the fourth one to decide whether to draw a banner.
The audit question is never the boolean. It is some version of "on what were they asked, and what was already running while they were being asked". There are two distinct ways to fail it. In the first, the record was never written: only the outcome was kept, so the system states a conclusion it cannot support. The second is subtler and more common in code that looks careful. The record exists, it has timestamps, it even carries a version label, and it is still not bound to the question it answered.
Bind the decision to the notice text, not to its label#
A version string is a promise somebody has to remember to keep. It gets bumped when the change feels big enough to bump it, so it does not get bumped for the edit that clarified a sentence, added a vendor to a list, or widened a purpose from "measure traffic" to "measure traffic and build audiences". That last edit changes what the visitor agreed to, and every stored decision still looks perfectly current.
So every decision records both, and the hash is the one that does the work:
const options = { noticeVersion: "2026-08-01", noticeHash: hashNotice(noticeText) };When either the version or the hash moves, restore returns the state to undecided and the banner is due again. The superseded answer is not deleted. It stays in state.log, where superseded(state) can find it, because "they agreed to the previous notice on this date and were re-asked when it changed" is a stronger story than a clean slate. That is the whole behaviour, and you can watch it in the demo output:
the notice text is edited; the version string is not
old decision carried forward: false
asked again : true
kept in the log : custom at 2026-08-16T10:00:00.000ZTwo honest notes about the hash. hashNotice is FNV-1a, a change fingerprint rather than a security hash. It answers "is this the same text we showed before", not "could an adversary construct a collision", and pretending otherwise would be the kind of claim that does not survive a review. It also only works if the application can reach the notice text to hash it, which means the notice becomes a deployed, versioned asset with the same lifecycle as the code, instead of a CMS field that someone edits on a Friday with nothing downstream noticing. We wanted that constraint.
Gate the scripts instead of racing the banner#
The most expensive consent bug we see is not a design bug. It is an ordering bug: an analytics snippet sits in the document head, and the banner mounts a moment later. The visitor's choice arrives after the request already went out. Correct banner design does not undo a request that has already been made, and no log entry written afterwards can describe it as consented.
The fix is to stop treating the decision as something that arrives after work starts, and to make the work itself conditional on a decision existing:
const gate = new Gate(state);
gate.when("necessary", "session", () => startSession());
gate.when("statistics", "analytics", () => loadAnalytics());
gate.when("marketing", "pixel", () => loadPixel());
// later, when the visitor decides
gate.update(accept(["statistics"], options));Three properties matter here, and each one is a decision rather than an implementation detail. Necessary work runs immediately and synchronously, so callers can rely on ordering instead of scattering readiness flags of their own. Nothing runs twice however many decisions arrive, because a visitor who changes their mind three times should not get three sessions. Work whose category is withdrawn gets dropped instead of waiting, since a queue that holds refused work in case the visitor comes back later is a loophole with a retry policy.
Tags keep running; queued work does not#
Queued work is a one-shot side effect: it runs once and it is over. A third-party tag is not. It keeps running, and consent can move under it after it started, so tags need a registry rather than a queue:
const tags = new Tags(state);
tags.register({ id: "analytics", purpose: "statistics", src: "https://cdn.example/a.js" });
tags.register({
id: "pixel",
purpose: "marketing",
src: "https://cdn.example/p.js",
cleanup: () => { document.cookie = "_pxl=; Max-Age=0"; },
});
tags.update(accept(["statistics"], options)); // analytics goes on the page, the pixel does not
tags.update(withdraw(options, state)); // the analytics element is taken off itTags are held until a decision exists, released only for the purposes that decision granted, and removed from the page on withdrawal. They are not neutralised by rewriting the type attribute to text/plain and left sitting in the document, which is the common pattern and which keeps a refused vendor one line of unrelated code away from running again.
cleanup is the part nobody can write for you. Taking an element off the page does not undo what it already did, and the cookie a tag set outlives the tag by design. That callback is per-vendor handwork, and it is what makes "withdrawal is as easy as consent" hold up.
The rest is inspectability. Elements carry data-consent-tag and data-consent-purpose, so you can open a page in a browser and ask what is running and under which answer, which is how a support engineer settles an argument in a minute instead of an afternoon. A later grant releases a removed tag again, a new answer to the same question rather than a replay of the old one, and tags.history records both directions. With no document, under server rendering, mounting is a no-op instead of a crash, because a consent library that breaks a server render will simply be deleted.
An append-only log is a promise the code makes to itself#
The log is append-only in memory. That is a real property, and a useless one to a reviewer, because a JSON file of decisions can be edited in any text editor and nothing in its shape shows that it was. "Trust our write path" is not evidence.
So the export chains each entry to the hash of the one before it:
{
"format": "consentcore/proof@1",
"entries": [
{
"index": 0,
"at": "2026-08-16T10:00:00.000Z",
"purposes": ["necessary", "statistics"],
"method": "custom",
"noticeVersion": "2026-08-01",
"noticeHash": "a1c1f39a4c52ebd8",
"previous": "0000000000000000000000000000000000000000000000000000000000000000",
"hash": "…"
}
]
}The chain is derived, never stored. Nothing new goes into localStorage, two exports of the same log are byte-identical, and anyone holding the log can recompute the chain without holding a secret. verifyProof(JSON.parse(text)) re-runs the whole check (hashes, links, positions and timestamp order) and returns every problem it found with the entry it belongs to, instead of stopping at the first. That return shape is deliberate: after "is this valid" the next question you get is "what exactly is wrong with it", and a boolean forces the reviewer to bisect the file by hand.
What it costs to run#
The notice text becomes something you version and ship, because you cannot hash what you do not have. Cleanup callbacks get written once per vendor and reviewed like any other code that touches storage. The log grows with decisions, not with page views, so the volume stays small and retention becomes a policy question rather than an infrastructure one. The export is computed on demand, so there is no new persisted artifact to keep in sync.
The operational cost is probably the one worth naming up front: gating fails closed. A tag registered under a purpose that no decision ever grants simply never runs, and "nothing ran" is quieter than "everything ran". That is the correct direction to fail in, and it is also why the granted path needs a test rather than a manual check before release, which on our side goes through the same review gate as every other change.
None of this solves consent. It does make consent checkable. The log entries name the text they answered, and the scripts running on a page can be traced back to that log. The export is something a reviewer can verify without taking anyone's word for the write path.