Files
my-pi/pi-permission-system/docs/decisions/0004-zod-config-schema-single-source.md
T

4.0 KiB

status, date
status date
accepted 2026-07-06

0004 — Zod as the single source of truth for config schema and validation

Status

Accepted.

Context

Issue #547 asked for a hosted JSON Schema so editors give completions and flag typos in the permission-system config. A schema already existed (schemas/permissions.schema.json), the config already accepted a $schema key, and the example config already set it — but two defects remained:

  • Every hosted URL ($id, the example config's $schema, doc references) pointed at raw.githubusercontent.com/gotgenes/pi-permission-system/…, the pre-monorepo upstream fork, not the monorepo path.
  • The JSON Schema was hand-maintained separately from the TypeScript types and the hand-rolled loader guards (value-guards.ts, normalizeUnifiedConfig), so the three could drift — the exact maintenance trap the package skill warns about.

The loader was also tolerant: it silently discarded a malformed field (a non-boolean debugLog, an invalid permission action, an unknown key) and loaded the rest, so a typo failed quietly.

Decision

Adopt zod (^4.4.3) as the single source of truth for the config-file shape (src/config-schema.ts):

  • Composable schemas (permissionStatedenyWithReasonpatternValuepermissionMappermission → the unified config) mirror the previous $defs structure. The config types (PermissionState, DenyWithReason, PatternValue, FlatPermissionConfig, UnifiedPermissionConfig) are derived with z.infer and re-exported from types.ts / config-loader.ts, so there is one definition, not three.
  • The published schemas/permissions.schema.json is generated from the zod source via z.toJSONSchema (Draft 2020-12) by pnpm run gen:schema; a parity test fails if the committed file drifts. The root $id and every doc/example $schema URL now point at the monorepo raw path.
  • The config-file loader validates via unifiedConfigSchema.safeParse. Rich editor metadata (markdownDescription, examples, per-value descriptions, default annotations) is carried through zod's .meta().

Validation is strict and fail-closed (breaking): a config file with any invalid field is rejected as a whole scope — it contributes an empty config, so missing surfaces fall through to the universal ask default rather than allow — and every violation is reported as a clear, path-qualified issue.

Scope boundaries

  • Per-agent frontmatter is not validated by this schema. Agent .md frontmatter carries non-config keys (name, description, model, …) alongside a permission: block; routing it through the strict strictObject would reject those keys. policy-loader.ts therefore extracts and tolerantly normalizes only the permission block, unchanged.
  • Legacy files keep their migration guidance without strict-validation noise. The move-it message is the actionable signal; the loader suppresses zod issues for legacy paths.
  • The flat-permission-to-Rule translation (normalize.ts, policy-loader.ts) is unchanged — it consumes already-validated config and keeps using isPermissionState / isDenyWithReason.

Consequences

  • One edit point for the config shape; the schema, types, and runtime validator cannot drift.
  • Typos and wrong-typed fields are caught — in the editor (via additionalProperties: false + types) and at load time (with a clear message) — instead of failing silently.
  • Breaking: a config that previously loaded with silently-dropped fields is now rejected until the reported problems are fixed; the affected scope falls back to ask until then (see docs/migration/strict-config-validation.md).
  • A new runtime dependency (zod) is added.
  • The config-only guards normalizeOptionalStringArray and normalizeOptionalPositiveInt were removed (superseded by zod), shrinking the scope of the still-open #532.