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 atraw.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 (
permissionState→denyWithReason→patternValue→permissionMap→permission→ the unified config) mirror the previous$defsstructure. The config types (PermissionState,DenyWithReason,PatternValue,FlatPermissionConfig,UnifiedPermissionConfig) are derived withz.inferand re-exported fromtypes.ts/config-loader.ts, so there is one definition, not three. - The published
schemas/permissions.schema.jsonis generated from the zod source viaz.toJSONSchema(Draft 2020-12) bypnpm run gen:schema; a parity test fails if the committed file drifts. The root$idand every doc/example$schemaURL now point at the monorepo raw path. - The config-file loader validates via
unifiedConfigSchema.safeParse. Rich editor metadata (markdownDescription,examples, per-value descriptions,defaultannotations) 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
.mdfrontmatter carries non-config keys (name,description,model, …) alongside apermission:block; routing it through the strictstrictObjectwould reject those keys.policy-loader.tstherefore extracts and tolerantly normalizes only thepermissionblock, 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-
Ruletranslation (normalize.ts,policy-loader.ts) is unchanged — it consumes already-validated config and keeps usingisPermissionState/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
askuntil then (seedocs/migration/strict-config-validation.md). - A new runtime dependency (
zod) is added. - The config-only guards
normalizeOptionalStringArrayandnormalizeOptionalPositiveIntwere removed (superseded by zod), shrinking the scope of the still-open #532.