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-workagainst this file. The work ships through the three children under Delivered by below; each links back here viaparent_key. This epic closes (closed/done) once all three children areclosed/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.ts — SdlcConfigSchema 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.
Proposed
Section titled “Proposed”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.
Delivered by
Section titled “Delivered by”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.
| Child | Slice | Maps to (this epic) | Dependencies |
|---|---|---|---|
| T-KESH-config-surface-sdlc-yaml-zod-safeparse | Config 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-11 | none |
| T-JO4I-entity-zod-schemas-validation-ops-swap | Entity 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-6 | none |
| T-DHUF-entity-docs-migration-cleanup-ajv-removal | Entity 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-9 | T-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).
Acceptance criteria
Section titled “Acceptance criteria”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 az.infertype export; at least one consumer hydrates an entity through the inferred type rather thanRecord<string, unknown>. (via T-JO4I-entity-zod-schemas-validation-ops-swap) - AC-E2: No remaining
schema.json/_common.jsonunderplugin/lib/model/entities/, no committedplugin/schemas/sdlc-yaml.schema.json, and noajv/Ajv/ajv-formatsimport 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:
ajvandajv-formatsare removed frompackage.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, andsdlc.yamlvalidation all run via.safeParsewith clear Zod-derived messages, and the docs site renders the data-model + per-entity pages fromzodToJsonSchema(EntitySchema)in memory (sdlc docs generate site --checkclean). (across all three children) - AC-E5:
S-0005-entity-definition-contract.mdreads 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 testandbunx tsc --noEmitare green. (across all three children)
Out of scope
Section titled “Out of scope”- 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_versionnumbering and any instance migration. The per-entityversionbecomes a Zod-side constant; whether/how to bumpschema_versionand re-stamp the instance files is an/sdlc:entities-migrateconcern, 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, notloadConfigitself. - The
sdlc.yamlconfig shape / supported keys. No keys are added, removed, or re-typed; only the validation engine moves from AJV to Zod.
Dependencies
Section titled “Dependencies”- none. This epic builds on PR #436 (
plugin/lib/config/load.ts— Zod-firstloadConfig), already merged tomain; no in-flight task gates it. The inter-child ordering (T-DHUF after T-KESH + T-JO4I) is captured on the children viadepends_on, not here.
Discovery context
Section titled “Discovery context”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).