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

108 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
issue: 547
issue_title: "Include a JSON Schema definition in pi-permissions.jsonc for completions and easier configuration"
---
# Retro: #547 — Include a JSON Schema definition in pi-permissions.jsonc for completions and easier configuration
## Stage: Planning (2026-07-06T00:00:00Z)
### Session summary
Planned issue #547 (third-party, filed by `JasonLandbridge`).
Discovered the requested JSON Schema already exists (`schemas/permissions.schema.json`), the config already accepts `$schema`, and completions technically already work — the real defects are a stale hosted URL (points at the pre-monorepo `gotgenes/pi-permission-system` fork) and a hand-maintained schema that drifts from the TS types and hand-rolled loader guards.
Through three `ask_user` rounds the operator chose the "Full" direction: adopt **zod** (pin `4.4.3`, the production `latest`) as the single source of truth, derive the Draft 2020-12 JSON Schema from it, and route the loader's runtime validation through it with **strict, breaking** semantics (reject a malformed field with a clear description, fail-closed to `ask`).
Wrote a 6-step lift-and-shift TDD plan and committed it.
### Observations
- Third-party issue → did not skip the `ask_user` gate; the operator materially reshaped scope (zod over the issue's TypeBox suggestion; runtime validation, not schema-only; stricter/breaking with clear errors).
- The operator interjected two directives mid-plan: use the current production zod (`4.4.3`, confirmed via `pnpm view`, since the `npm` shim is blocked) and lean on schema composability without over-abstraction — folded both into the design (bottom-up composable schemas mirroring the existing `$defs`, `reused: "ref"`).
- Key design guards captured: **no `.default()` in the parse schema** (defaults belong post-merge in `normalizePermissionSystemConfig`, else global/project override semantics break); allow `$schema` explicitly under `strictObject`; **fail-closed to `ask`** on reject is the security-critical invariant and gets its own test.
- `markdownDescription` is not confirmed to auto-copy from `z.toJSONSchema` ([colinhacks/zod#5272] is open) — the Step 2 parity test decides whether `.meta()` suffices or the `override` callback is needed.
- Scope interaction with open roadmap Step 8 ([#532]): #547 removes the two config-only guards (`normalizeOptionalStringArray`, `normalizeOptionalPositiveInt`) that #532 meant to keep, and keeps the domain guards #532 meant to move — noted as a Non-Goal, updating `architecture.md` line 807/906 rather than completing #532.
- No follow-up issues filed: deeper `normalize.ts`/`policy-loader.ts` guard cleanup is deferred to existing #532; per-agent frontmatter validation left as an Open Question (not speculative-filed).
- Release: ship independently — #547 is not in the architecture roadmap.
- Freshness gate is a vitest parity test (runs in the existing `pnpm -r run test` CI job), so no `ci.yml` edit is needed.
## Stage: Implementation — TDD (2026-07-06T17:26:46Z)
### Session summary
Implemented all 6 planned TDD steps across 6 commits: added the composable zod schema module (`config-schema.ts`) as the single source of truth, generated `permissions.schema.json` from it (fixing the stale `$id` URL), routed the config-file loader through strict `safeParse` (breaking, fail-closed), removed the two superseded config-only guards, derived the config types from zod, and updated docs/ADR/migration/skill.
Package tests went 2283 → 2293 (+10 net: +22 from `config-schema.test.ts`, +1 fail-closed test, 13 from removed guard tests).
All deterministic gates pass; pre-completion reviewer returned WARN (one finding, fixed).
### Observations
- **Two unplanned but necessary deviations, both honoring the plan's scope.**
(1) `normalizeUnifiedConfig` turned out to be dual-purpose — it also validated per-agent `.md` frontmatter (which carries non-config keys like `name`/`model`).
A strict `strictObject` would have rejected those and silently dropped agent permission blocks.
Fix: `policy-loader.ts` now extracts only the `permission` block via the exported tolerant `normalizeFlatPermissionValue`, so config files are strict while agent frontmatter stays tolerant (the plan's stated boundary). (2) Legacy-file validation issues are suppressed in `loadAndMergeConfigs` so the move-it migration message stays the clean, actionable signal.
- **The generated schema is not byte-identical to the hand-maintained one** — zod emits `anyOf`-of-consts (not `oneOf`) and adds a safe-int `maximum` on integers.
Functionally equivalent for editors; the parity test snapshots the generator output for freshness rather than matching the old file.
`markdownDescription`, `examples`, `default`, and per-value descriptions all survive via `.meta()` (no `override` callback needed).
- **`gen:schema` chains `biome format`** because `JSON.stringify(…, null, 2)` collapses differently than biome (single-element arrays); chaining keeps the committed file deterministic and lint-clean.
- **No `.default()` in the parse schema** — defaults stay in `normalizePermissionSystemConfig` post-merge, preserving global-vs-project override semantics (a plan guard that held up).
- **Type derivation was safe** — `expectTypeOf(...).toEqualTypeOf(...)` in `config-schema.test.ts` (enforced by `tsc`, since tsconfig includes `test`) proved the `z.infer` types equal the hand-written ones before the lift-and-shift, so all ~58 consumers compiled untouched.
- **Pre-completion reviewer: WARN** — the only finding was a missing README Documentation-table row for the new `docs/migration/strict-config-validation.md`; added it (amended into the docs commit) and re-verified lint.
Everything else PASS, including explicit tests for the fail-closed `ask` fallback and legacy-file suppression.
- **#532 interaction** — removed the two config-only guards it planned to keep; updated `architecture.md`'s Step 8 note and the ADR to reflect the shrunk target without closing #532.
- **zod `4.4.3`** installed cleanly, no `minimumReleaseAgeExclude` needed; tarball verified to ship `src/config-schema.ts` + schema + migration doc and exclude `scripts/`, `test/`, and the internal ADR.
## Stage: Final Retrospective (2026-07-06T18:00:00Z)
### Session summary
One continuous session carried #547 through planning, TDD implementation, and ship.
Shipped `pi-permission-system` `v19.0.0` (major, breaking): zod is now the single source of truth for the config-file shape, the JSON Schema and config types both derive from it, the stale hosted `$id`/`$schema` URLs are repointed to the monorepo, and config-file validation is strict and fail-closed.
Six implementation commits, `+10` net tests, pre-completion WARN fixed, CI green, release-please PR #550 merged, issue closed.
### Observations
#### What went well
- **Discovery-first planning reframed the issue.**
The request was "add a JSON Schema," but investigation found the schema already existed and completions already worked; the real defects were a stale hosted URL and schema/type/loader drift.
Reframing avoided building a redundant artifact and produced a higher-value single-source-of-truth outcome.
- **Throwaway exploration scripts before committing to the schema shape.**
Two disposable `explore-zod.ts` runs revealed that `reused: "ref"` produced ugly `__schemaN` `$defs` and that `.meta({ id })` alone yields clean `$defs` — and that `markdownDescription`/`examples`/`default` all pass through `.meta()` (no `override` needed).
This is the `testing` skill's "write a disposable exploratory script first" rule paying off directly.
- **Caught a latent regression during implementation.**
`normalizeUnifiedConfig` was dual-purpose (config files *and* per-agent frontmatter); a naive strict-ification would have silently dropped agent `permission` blocks (frontmatter carries `name`/`model`/etc.).
Grepping the callers surfaced it, and the fix kept agent frontmatter tolerant while config files went strict.
- **Proactively checked biome warnings (exit 0).**
After removing test blocks, an unused `it` import lingered as a biome *warning* — which `pnpm run lint` passes and the pre-completion reviewer's error-gated checks would miss.
Explicitly inspecting `biome check` warnings caught it before commit.
- **Incremental verification, no feedback-loop gap.** `pnpm run check` / the affected test file / `pnpm run lint` ran after *every* TDD step, and the full suite + `fallow dead-code` + a `pnpm pack` tarball inspection ran at the end. `expectTypeOf(...).toEqualTypeOf(...)` (enforced by `tsc`) de-risked the ~58-consumer type lift-and-shift before it landed.
- **Ship-flow rule adherence under an in-progress release check.**
The release-please PR reported `UNSTABLE` with a check still `IN_PROGRESS`; per the ship prompt I waited and re-polled rather than falling back to `gh pr merge` while the check ran, then merged clean via `release_pr_merge`.
#### What caused friction (agent side)
- `other` (path typo) — one `Edit` used an absolute path missing the `pi-packages/packages/` segment, tripping the permission gate.
Impact: one rejected call + retry, no rework.
- `missing-context` — did not anticipate that biome formats committed JSON, so the first `gen:schema` output failed lint (biome collapses single-element arrays differently than `JSON.stringify`).
Impact: one fix cycle; resolved by chaining `biome format --write` into `gen:schema` so the generated file is deterministic and lint-clean.
#### What caused friction (user side)
- None.
The operator's two mid-planning interjections (zod over TypeBox; pin the latest production version + favor schema composability) were well-timed and materially improved the design; they could not have come earlier since the operator did not yet know the schema already existed.
### Diagnostic details
- **Model-performance correlation** — one subagent dispatch: `pre-completion-reviewer` on `anthropic/claude-sonnet-5` (274s, 53 tool uses) for judgment-heavy review (acceptance criteria, design review, doc staleness, Mermaid render).
Appropriate model for the work; no mismatch.
- **Escalation-delay tracking** — no rabbit holes; the longest same-target sequence was the two deliberate zod-exploration script runs, which were investigative, not stuck.
- **Unused-tool detection** — `web_search` + `fetch_content` (zod v4 `toJSONSchema` docs) and disposable scripts covered the unfamiliar-library risk; no Explore/Plan subagent was warranted (full context was already in-session).
- **Feedback-loop gap analysis** — none: verification ran incrementally after each step rather than only at the end.
### Changes made
1. Added this Final Retrospective stage entry to `packages/pi-permission-system/docs/retro/0547-zod-config-schema-single-source.md`.
No prompt, `AGENTS.md`, or skill changes: the operator chose retro-only, since the `gen:schema` script already self-documents the `biome format` chaining and the other candidate lessons duplicate existing guidance.
[#532]: https://github.com/gotgenes/pi-packages/issues/532
[colinhacks/zod#5272]: https://github.com/colinhacks/zod/issues/5272