Skip to content

T-QFTI-migrate-entity-schemas-to-zod-first

Status: closed/done · Impact: high · Complexity: large

Epic — not worked directly. This is a parent task (an organizational rollup, not a directly-executable unit) per D-ORMG-data-model: a Task with children is an epic, and orchestration skips it in favour of its leaf children. Do NOT run /sdlc:task-work against this file. The work ships through the three children under Delivered by below; each links back here via parent_key. This epic closes (closed/done) once all three children are closed/done.

Make Zod the only schema artifact across both surfaces that carry a schema today: the entity schemas (11 entity types) and the sdlc.yaml config pipeline. This is one architectural decision applied twice — Zod is the single source of truth; no checked-in JSON schema, no AJV; validation is .safeParse; JSON survives only as an OPTIONAL on-demand export. After this epic there is no AJV in the runtime, no committed *.schema.json consumed for validation, and no drift test guarding a generated JSON copy.

The config surface already proved the target pattern: PR #436 made sdlc.yaml Zod-first (plugin/lib/config/load.tsSdlcConfigSchema is the source, z.infer gives the type, ConfigSchema.parse validates and fills defaults), but deliberately left a bridge: AJV (schema.ts) + a generated sdlc-yaml.schema.json + its codegen + a drift test, all still wired through validateFile / loadVerbList. This epic finishes that cleanup AND does the same cut for entities, removing both bridges so ajv + ajv-formats can leave package.json entirely.

The entity surface is the larger half and delivers the headline win the current model can’t give: typed hydration. Entities are read untyped today (frontmatter[key] over a Record<string, unknown>), so every consumer hand-casts fields the schema already constrains. A Zod schema gives z.infer types for free and replaces AJV .validate with .safeParse. The owner chose the full cut over a “bridge” (keep AJV, generate the JSON from Zod): the bridge carries two schema systems forever for no benefit, since nothing external consumes the JSON — it is read only in-process for validation, scaffolding defaults, pattern-walking, and docs. One schema language, typed at the source, is the clean end-state; the honest cost is more upfront work (validator path + docs source + goldens all swapped at once, across both surfaces), accepted deliberately.

Two surfaces still carry an AJV + JSON-schema bridge. The entity schemas are 11 checked-in schema.json files plus a shared _common.json, validated by AJV with the common fragment inlined at load time. The sdlc.yaml config pipeline (PR #436 made load.ts Zod-first) still keeps the AJV core, the generated sdlc-yaml.schema.json, its codegen, and a drift test alive for the non-loadConfig readers. The full file-level inventory of every stale schema.json / _common.json / sdlc-yaml.schema.json / AJV reference is distributed into the three children’s ## Today tables — each child owns the surface it migrates.

One Zod schema per entity is the source of truth. A shared CommonFrontmatter Zod base (the _common.json replacement) is .extend()-ed per entity, giving each a typed z.infer. Validation everywhere runs .safeParse. The docs generator calls zodToJsonSchema(EntitySchema) in memory at generation time and renders from the resulting JSON-Schema-shaped object — its existing rendering logic survives almost unchanged, and it never hand-walks Zod’s _def internals. The checked-in schema.json / _common.json files are deleted; the optional on-demand generated JSON export (for editor/YAML-LS autocomplete) exists only if/when wanted, never persisted by default. The Python-style error-parity goldens are dropped and replaced with Zod-error assertions. S-0005 reads Zod-first; the enumerated standards/decisions/conventions are updated in place. The config surface end-state is the same cut. Both surfaces share the final dependency removal — ajv + ajv-formats come out of package.json only once BOTH are off AJV.

The owner decided to break this epic’s single large delivery into three reviewable PRs instead of one mega-PR. The original Approach (11 numbered steps), the “Config surface scope” todos, the “Files to touch” table, and ACs 1–11 are distributed across the three children below; each is an implementation-ready spec carrying the slice of this epic it owns. Children link back here via parent_key.

ChildSliceMaps to (this epic)Dependencies
T-KESH-config-surface-sdlc-yaml-zod-safeparseConfig surface (sdlc.yaml): retire the AJV core, delete the generated sdlc-yaml.schema.json + codegen + drift test, Zod-issue goldens, amend sdlc-yaml.md. Does NOT remove ajv from package.json (entity surface still uses it).”Config surface scope” todos; AC-10, AC-11none
T-JO4I-entity-zod-schemas-validation-ops-swapEntity Zod schemas + validation/ops swap: CommonFrontmatter base + 11 per-entity schema.ts + registry; entity.ts / validate.ts / audit.ts / migrate.ts / authoring.ts / schema_patterns.ts onto .safeParse. Leaves all schema.json + _common.json on disk.Approach 1–6; AC-1, AC-6none
T-DHUF-entity-docs-migration-cleanup-ajv-removalEntity docs migration + cleanup + shared ajv removal: docs generator + remaining JSON consumers onto in-memory zodToJsonSchema; delete the 11 schema.json + _common.json; drop the Python-style error-parity goldens; amend S-0005 / D-0004 / typescript-runtime.md; remove ajv + ajv-formats from package.json.Approach 7–11; AC-4, AC-5, AC-7, AC-8, AC-9T-KESH AND T-JO4I

Dependency order: T-KESH and T-JO4I are independent and land in parallel; T-DHUF is the shared final gate and lands last (it depends_on both — the shared package.json ajv removal needs config off AJV, and the deletion of schema.json plus the goldens drop needs entity validation already on Zod).

Epic-level rollup. The per-slice ACs (the original AC-1…AC-11, distributed) live in the children; this epic is satisfied when all three children are closed/done and the end-state below holds across both surfaces.

  • AC-E1: Each of the 11 entity types has one Zod schema (CommonFrontmatter.extend(...)) as its source of truth with a z.infer type export; at least one consumer hydrates an entity through the inferred type rather than Record<string, unknown>. (via T-JO4I-entity-zod-schemas-validation-ops-swap)
  • AC-E2: No remaining schema.json / _common.json under plugin/lib/model/entities/, no committed plugin/schemas/sdlc-yaml.schema.json, and no ajv / Ajv / ajv-formats import anywhere in the runtime except, at most, the optional on-demand JSON-export path. (via T-KESH-config-surface-sdlc-yaml-zod-safeparse + T-DHUF-entity-docs-migration-cleanup-ajv-removal)
  • AC-E3: ajv and ajv-formats are removed from package.json — the shared gate, verified only after BOTH surfaces are off AJV. (via T-DHUF-entity-docs-migration-cleanup-ajv-removal)
  • AC-E4: sdlc entities validate, sdlc entities audit, and sdlc.yaml validation all run via .safeParse with clear Zod-derived messages, and the docs site renders the data-model + per-entity pages from zodToJsonSchema(EntitySchema) in memory (sdlc docs generate site --check clean). (across all three children)
  • AC-E5: S-0005-entity-definition-contract.md reads Zod-first; D-0004, the conventions docs (entity + sdlc-yaml.md), and the .eta/template references no longer assert JSON-schema-of-record or AJV; bun test and bunx tsc --noEmit are green. (across all three children)
  • A separate ADR for this decision. The owner deliberately chose a task, not a new decision document. D-0004 (entity-definition-architecture) is the ADR that today asserts schema.json-of-record / AJV-validation; it is amended in place by this epic (in T-DHUF-entity-docs-migration-cleanup-ajv-removal) rather than superseded by a new ADR — consistent with the alpha-phase “rewrite the doc in place” preference for documents that are the live contract. No genuinely new architectural decision is being made (the schema-language choice was already settled with the config precedent in PR #436).
  • The schema_version numbering and any instance migration. The per-entity version becomes a Zod-side constant; whether/how to bump schema_version and re-stamp the instance files is an /sdlc:entities-migrate concern, run after this epic lands.
  • The body-section contract (body-schema.yaml + the body validator). A separate surface from frontmatter schema; stays as-is. Only the frontmatter schema moves to Zod here.
  • Persisting any generated JSON schema by default. The optional on-demand export (editor/YAML-LS autocomplete) is a stub left for if/when wanted; not a checked-in artifact, no drift test.
  • Re-architecting the Zod source plugin/lib/config/load.ts. It is already the target pattern (PR #436); this epic removes the AJV + generated-JSON bridge around it, not loadConfig itself.
  • The sdlc.yaml config shape / supported keys. No keys are added, removed, or re-typed; only the validation engine moves from AJV to Zod.
  • none. This epic builds on PR #436 (plugin/lib/config/load.ts — Zod-first loadConfig), already merged to main; no in-flight task gates it. The inter-child ordering (T-DHUF after T-KESH + T-JO4I) is captured on the children via depends_on, not here.

Architectural decision settled by the project owner: full-Zod — Zod is the only schema artifact; no checked-in JSON schema, no AJV; validation is .safeParse; JSON survives only as an optional on-demand export. It applies to BOTH surfaces that carry a schema: the entity schemas and the sdlc.yaml config pipeline. The owner explicitly rejected the “bridge” (keep AJV, generate JSON from Zod) because nothing external consumes the JSON, choosing the full cut to the clean end-state. PR #436 delivered config Zod-first (load.ts) but deliberately left the config-side bridge; this epic finishes that cleanup alongside the entity cut so the shared ajv + ajv-formats dependency can leave package.json for good.

This task was originally authored as a single large task carrying the complete Approach (11 steps), the “Config surface scope” todos, the “Files to touch” table, and ACs 1–11. The owner then decided to deliver it as three reviewable PRs; this file was reframed into the parent epic and its implementation detail distributed into the three children under Delivered by, each linking back via parent_key per D-ORMG-data-model (Epic retired into Task self-nesting).


← Back to Tasks