How it works
The mental model behind policykit: a version is a directory, two dates decide what binds, and three notice tiers decide what you owe users.

policykit answers three questions about a legal document: which text binds right now, what notice you owe users, and how to render any past text again. Here is the model behind the answers.
Why a version is a directory
No manifest, no registry, no database table. A revision exists because a directory named after its date exists:
policies/terms-of-service/
2026-01-10/
en.mdx
2026-06-01/
en.mdx
2026-06-20/
en.mdx
The directory name IS the revision's identity. Adding a version means adding a folder.
That buys the audit trail for free: git log over the policy directory records who wrote which
text and when, so you can show what a user could have seen on the day they accepted. Which is why
you never rewrite history over policy content - the log is part of the acceptance-evidence chain.
The two dates that matter
Every revision carries two dates, and mixing them up is the most common bug:
revision- the directory name. When the text was published.effectiveFrom- a frontmatter field, validated to be on or afterrevision. When the text starts to bind.
The gap between them is the notice window you granted. Three accessors read it differently
(latest, effective and pending in src/policy/policy.ts):
latest()- the newest revision byrevision. Published, possibly not binding yet.effective(now)- the newest revision whoseeffectiveFromhas arrived. What the live page renders, aseffective(now) ?? latest().pending(now)- the revision that binds next, whatever its tier. What one policy's own page labels as its next version.
Neither of the last two is the consent gate. That is requiredConsentRevision(policies, now), and
it is what an acceptance stamps - see notices and consent. A site-wide
"takes effect on " banner reads pendingNotice(policies, now), which skips none revisions
where pending() would announce them.
Take one policy with these three revisions:
revision | effectiveFrom | notice |
|---|---|---|
2026-01-10 | 2026-01-10 | none |
2026-06-01 | 2026-07-01 | notify |
2026-06-20 | 2026-06-25 | reconsent |
On 15 June 2026, latest() returns 2026-06-20, effective(now) still returns 2026-01-10, and
pending(now) returns 2026-06-20. Ten days later, on 25 June, effective(now) returns
2026-06-20 and pending(now) returns undefined.
What each notice tier means
notice is a recorded human judgement, not something the package computes
(the PolicyNotice type in src/policy/types.ts). Unsure between two tiers? Record the stricter
one.
none- non-material, or forced by law or security. Nothing queued, nothing announced.notify- users must be told, but existing consent stands. It appears innoticeQueue()until itseffectiveFromages past the notice horizon (DEFAULT_NOTICE_HORIZON_DAYS, 60 days), and in the banner; it never re-prompts anyone.reconsent- users must expressly accept again. Once effective, it moves the valuerequiredConsentRevision()returns, so every older stored acceptance fails the gate.
One case the tiers do not cover: a policy's baseline - the first revision that ever binds - sets
the consent gate whatever its tier, none included. That is what everyone accepted at signup. So
adding a whole new policy re-gates consent even if its first revision owes nobody notice. After the
baseline, only reconsent moves the gate.
When a revision never binds
A revision is superseded when any newer revision takes effect on or before its own
effectiveFrom (supersededFlags in src/policy/policy.ts). In the table above, 2026-06-01 is
superseded:
2026-06-20 binds six days earlier, so the older text never binds for a single moment. This
happens for real - an immediate law or security change shipped while an earlier notice window is
still running.
Superseded revisions stay in the archive and revisions() still lists them, but pending(),
owedNotices(), noticeQueue(), pendingNotice() and requiredConsentRevision() all skip them:
announcing or gating on a text that will never bind is misinformation. If the superseded change
survives in the new text, record its tier on the superseding revision instead.
Why old revisions never leave
The directory is the archive. Nothing is ever deleted or rewritten, so revision(date) and
content(date, locale) serve any past pair for as long as its directory exists - that is how you
show a user the exact text they accepted two years ago. has(date, locale) is the cheap existence
check for a route that has to decide between rendering and a 404.
A locale missing from an old revision is a legal state, not an error: a language introduced later
never backfills the archive, so content() answers undefined and you decide what that means.
Why dates are UTC days and revisions are strings
now reduces to its UTC calendar day. Every binding comparison runs
now.toISOString().slice(0, 10) (isoDay in src/policy/policy.ts), so a revision takes effect
at UTC midnight of its effectiveFrom - identically for every user and every server, never at each
server's local midnight.
Revisions stay zero-padded ISO strings, end to end, compared with lexicographic >= and never
converted to Date. Zero-padded ISO sorts chronologically as text, so the comparison is exact and
there is no timezone or parser to get wrong. Compare a stored acceptance the same way, guarding the
never-accepted case: required === "" || (accepted ?? "") >= required. The full recipe is on
notices and consent.
Why nothing is read until you ask
new Policy({...}) touches no disk; it validates the config and stores it. Consumers hold
module-scope instances their auth layer imports transitively, so a malformed policy file must fail
loudly at first use - in next build, or in assertValid() in a test - rather than crash the app
at import time.
The walk is never memoized either: every accessor re-walks the tree, so next dev sees an edit
immediately and a validation error re-throws on every call. Only the per-file parse is cached: the
loader serves a memoized parse while the file's (mtimeMs, size) is unchanged, and writes the
cache only after validation succeeds, so a failed parse caches nothing.
See validation for what the walk enforces, and notices and consent for wiring the tiers into an app.