PolicykitDocs

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.

5 min readUpdated 11 August 2026
Validation

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 build fails during page-data collection, the moment a page calls effective(), latest(), or content().
  • 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:

FieldTypePresent
slugstringalways
filestring | undefinedwhen the violation is tied to one entry, relative to the policy dir (e.g. "2026-07-28/en.mdx")
fieldstring | undefinedwhen 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

ConditionFix
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 directoryPoint it at the policy directory.
The policy directory holds no revisionsA policy needs at least one YYYY-MM-DD/ directory.
A revision directory name is not a real, zero-padded calendar dayRename it - 2026-8-5 and 2026-02-30 both fail.
A stray file or directory sits at the policy rootDelete 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>.mdxRemove it or rename it. Revision directories hold locale files and nothing else.
A locale file's locale is not in the configured localesAdd the locale to the Policy config, or delete the file.
A revision has no default-locale fileAdd <revision>/<defaultLocale>.mdx; it carries the revision's frontmatter.
effectiveFrom is missing, or not a real calendar dayQuote it and check the day exists.
effectiveFrom is earlier than the revision directory nameRaise it, or rename the directory - a revision cannot take effect before it exists.
notice is missing or outside none / notify / reconsentRecord the tier the change actually owes.
changeSummary is missing or blankWrite one sentence saying what changed and why the tier is right.
A frontmatter key is not in the allowed setRemove it. Default-locale files allow effectiveFrom, notice, changeSummary, title; other locales allow title only.
The frontmatter is not a YAML mapping, or is invalid YAMLRestore the key: value shape; a stray colon in an unquoted string is the usual cause.
An MDX body is emptyWrite the text under the frontmatter - the body IS the legal document.
A locale exists at revision R but is missing from a later revisionAdd 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.

Was this page helpful?