Validation
Catch a broken policy corpus in CI and at build time, and read the PolicyValidationError that names the file, the field, and the slug.

A malformed policy directory throws a PolicyValidationError naming the offending entry, and the
frontmatter field when the violation is one. This page covers when that throw happens, how to force
it in CI, and the failures you will actually hit.
When validation runs
new Policy({...}) does zero filesystem access. It checks only what needs no IO - dir is
absolute, defaultLocale is in locales - and stores the config. Everything else waits for the
first accessor call.
That is deliberate. Consumers hold module-scope Policy instances that their auth layer imports
transitively, so a typo in a frontmatter block must not crash the app at boot. Instead it fails at
first use, which in practice means two places:
next buildfails during page-data collection, the moment a page callseffective(),latest(), orcontent().assertValid()fails in CI, which is where you want to find it.
Validate the whole set in CI
One test proves a deploy cannot fail on policy content:
import { it } from "vitest";
import { assertValidAll } from "@daanvandenbergh/policykit";
import { POLICIES } from "@/content/policies";
it("policies are valid", () => assertValidAll(POLICIES));
assertValidAll calls each policy's assertValid() and adds one check no single policy can make:
it rejects duplicate slugs across the set. That matters because acceptance records key on the
slug. Two policies sharing one makes every stored acceptance ambiguous - you cannot tell which
document a user agreed to.
Reading a PolicyValidationError
The error carries the same facts structurally that the message states in prose, so you can log or render them without parsing text:
| Field | Type | Present |
|---|---|---|
slug | string | always |
file | string | undefined | when the violation is tied to one entry, relative to the policy dir (e.g. "2026-07-28/en.mdx") |
field | string | undefined | when the violation is a frontmatter field (e.g. "effectiveFrom") |
Every message a policy directory produces is prefixed Policy "<slug>": and names the entry and
field it has. file is absent when the violation is not tied to one entry - a relative dir, a
missing directory, a duplicate slug.
One message is not prefixed: the cross-policy duplicate-slug error reads Duplicate policy slug "<slug>" - every policy must have a unique slug. and carries only slug. Do not match on the
prefix to detect a policy error; use instanceof.
import { assertValidAll, PolicyValidationError } from "@daanvandenbergh/policykit";
try {
assertValidAll(POLICIES);
} catch (error) {
if (error instanceof PolicyValidationError) {
console.error(`[${error.slug}] ${error.file ?? "-"} ${error.field ?? ""}: ${error.message}`);
}
throw error;
}
PolicyValidationError is exported from the package root, so any server-side code can use it for an
instanceof check. Do that in a server component, route handler, or test - not in a "use client"
tree. The root entry re-exports the Policy class, whose graph reaches the loader and node:fs, so
importing anything from it in a client bundle fails. The root entry's promise is that it pulls in no
react and no next, not that it is free of Node built-ins.
Errors are never cached
The directory walk is not memoized. Every accessor re-walks the policy directory, so a violation
re-throws on every call - a broken build fails loudly each time, never once-then-silently-green. The
same property makes next dev pick up your edits live.
Only the per-file parse is cached, on the Policy instance, keyed by (mtimeMs, size). The cache
is written only after validation succeeds, so a failed parse caches nothing and the next call
retries.
The one blind spot is inherent to stat-based caching: a rewrite that lands on the same byte size and the same timestamp is not detected. Touch the file or restart the process if you ever hit it.
What fails validation
| Condition | Fix |
|---|---|
dir is a relative path (throws at construction) | Pass an absolute path - path.join(process.cwd(), "policies", "terms-of-service"). |
defaultLocale is not in locales (throws at construction) | Add it to locales, or drop the defaultLocale override. |
dir does not exist, or is not a directory | Point it at the policy directory. |
| The policy directory holds no revisions | A policy needs at least one YYYY-MM-DD/ directory. |
| A revision directory name is not a real, zero-padded calendar day | Rename it - 2026-8-5 and 2026-02-30 both fail. |
| A stray file or directory sits at the policy root | Delete it, or move it into drafts/ - the only entry the walk skips, and only when it is a directory. |
A file inside a revision directory is not <locale>.mdx | Remove it or rename it. Revision directories hold locale files and nothing else. |
A locale file's locale is not in the configured locales | Add the locale to the Policy config, or delete the file. |
| A revision has no default-locale file | Add <revision>/<defaultLocale>.mdx; it carries the revision's frontmatter. |
effectiveFrom is missing, or not a real calendar day | Quote it and check the day exists. |
effectiveFrom is earlier than the revision directory name | Raise it, or rename the directory - a revision cannot take effect before it exists. |
notice is missing or outside none / notify / reconsent | Record the tier the change actually owes. |
changeSummary is missing or blank | Write one sentence saying what changed and why the tier is right. |
| A frontmatter key is not in the allowed set | Remove it. Default-locale files allow effectiveFrom, notice, changeSummary, title; other locales allow title only. |
| The frontmatter is not a YAML mapping, or is invalid YAML | Restore the key: value shape; a stray colon in an unquoted string is the usual cause. |
| An MDX body is empty | Write the text under the frontmatter - the body IS the legal document. |
| A locale exists at revision R but is missing from a later revision | Add the missing translation. Once introduced, a locale must exist for every revision after it. |
For the full grammar behind each of these, see policy directories.