Refusing consent is the same call as granting it.
Most consent code treats refusal as an absence: the user didn't tick the box, so nothing gets written. Then the audit question arrives. Did this person decline analytics, or were they never asked? An empty record answers both, which means it answers neither.
So in consentcore, our open-source consent library, grant() and refuse() are deliberately the same function twice. Same parameters, same order, same return value, same entry in the log. A refusal is not a missing grant, it is a decision with a timestamp, and it costs the caller exactly as much code to record as a yes.
The asymmetry lives in the effect, not the signature.
A grant is additive. It adds to what was already granted and leaves the rest of the state alone.
A refusal is subtractive and narrow. It takes away what it names, and nothing else. It does not reset the record, because a later "no" to marketing is not a withdrawal of everything decided before it.
One exception, and it is not negotiable: necessary survives being named. If a caller refuses the necessary category, the call still succeeds, still writes a log entry, and the category stays on. There is no state in which a service refuses its own ability to function, so the library will not construct one.
The second decision is that purposes and vendors are declared as data, not as identifiers scattered across call sites. Every purpose belongs to a category, and a purpose is answered through its category. Two purposes under one category are one answer. You cannot end up with analytics granted for one vendor and refused for another under the same declared category, because there is only one place that answer can be stored.
That constraint annoys people for about a day, and then it starts paying. It is what makes the state finite, the log replayable, and the question "what is this user's current answer for X" a lookup rather than a reconstruction.
The general rule we keep relearning in compliance tooling: if your negative path is cheaper to write than your positive path, it will probably be under-recorded, and the gap only becomes visible when someone external asks you to prove a decision existed.
So it matters whether you store a refusal today as a row, or as the absence of one.