We were reading a consent record that should not have existed anymore.
The decision in it was more than a year old. Our code knew it was stale — the caller got nothing back, the banner came up again, the user re-consented. Functionally correct. But the old record was still sitting in localStorage, untouched, because we had filtered it on the way out instead of removing it.
That gap cost us a conversation we did not want to have. When someone asks what you store and for how long, "we ignore it after thirteen months" is a weaker answer than "it is gone." The first one is a code path. The second one is a fact about the device.
So in consentcore, our open-source consent library, the expiry rule lives in the storage layer and both adapters route every stored record through it before a caller sees anything.
What we do differently now:
Expiry is computed from the decision's own timestamp, not from when we happened to look. Thirteen months is the default, overridable per store.
Month arithmetic takes the shorter window on purpose. A decision made on the 31st, landing in a month with 30 days, rolls back to the last day of that month rather than forward into the next one. When there is ambiguity about a boundary, we expire earlier, never later.
An unparseable timestamp evaluates to the epoch, so it counts as already over. A record we cannot read is not a record we trust.
Equality counts as past. The instant it expires, it is expired.
A hand-edited entry is refused whole, not half-read. Either the shape is right and the timestamp is still inside the window, or the entry is dropped entirely. We never reconstruct intent from a partially valid object.
And the part that actually closed the gap: read filters the log, writes the shortened log back, and removes the key outright when nothing survives. The cookie adapter does the same thing through its write path, and derives Max-Age from the expiry of the last surviving decision — so the browser drops the cookie on the same schedule our code does. Two clocks, one deadline.
The reason that last detail matters is that storage you only filter is storage you still hold. A reader that cleans up as it goes is the only version of this that stays true when nobody is looking at the code.
We have run compliance tooling in production for years — KYC flows, audit exports, retention rules — and this is the pattern that keeps coming back. Retention is not a check you perform. It is a state your storage is in.
If you have a retention window in your product: is it enforced where you read, where you write, or only in the policy document? I am curious which of the three is actually load-bearing for you.