--- status: accepted date: 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 (`permissionState` → `denyWithReason` → `patternValue` → `permissionMap` → `permission` → 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]. [#532]: https://github.com/gotgenes/pi-packages/issues/532 [#547]: https://github.com/gotgenes/pi-packages/issues/547