T-JO4I-entity-zod-schemas-validation-ops-swap
Status: closed/done · Impact: high · Complexity: large
Author one Zod schema per entity type (the _common.json base + 11 per-entity
schemas as a shared CommonFrontmatter.extend(...)), giving each a typed
z.infer, and swap the validation and ops layer from AJV to .safeParse.
This is child 2 of the three-way split of
T-QFTI-migrate-entity-schemas-to-zod-first (the epic). It delivers the
headline win the current model can’t give — typed hydration — by replacing
AJV .validate over a Record<string, unknown> with .safeParse against a
typed schema. Critically, this slice leaves all 11 schema.json + _common.json
on disk: the docs generator, entity discovery, and the remaining JSON consumers
still read JSON in this slice (they migrate in child 3
T-DHUF-entity-docs-migration-cleanup-ajv-removal). Only validation and ops
move to Zod here; tests for both the JSON path and the Zod path stay green.
Entity schemas are checked-in JSON Schema (Draft 2020-12), validated by AJV, with
the common frontmatter factored into one _common.json fragment that each
per-type schema pulls in via allOf: [{ "$ref": "../_common.json" }]. The
framework inlines that $ref at load time (resolveSchema in entity.ts)
because AJV’s additionalProperties:false can’t see $ref-introduced properties
and because many consumers walk the loaded schema object directly.
| Location | Role today |
|---|---|
plugin/lib/model/entities/_common.json | Shared frontmatter base (type/schema_version/id/status/title/created/last_reviewed/related/tags/need_human_review), $ref’d by every per-type schema. Stays on disk in this slice |
plugin/lib/model/entities/backlog/schema.json (and capability, decision, driver, milestone, principle, product, reference, standard, task, term) | The 11 per-entity JSON Schemas — required fields, enums, patterns, default keywords, version. Stay on disk in this slice; deleted in child 3 |
plugin/lib/model/entity.ts | The heart: loadEntitySchema reads schema.json, resolveSchema inlines the _common.json $ref, validateFrontmatter runs AJV (Ajv2020, useDefaults), scaffold reads properties[*].default |
plugin/lib/model/ops/validate.ts | sdlc entities validate — own AJV instance + translateAjvError (Python-jsonschema-style messages); resolves explicit --schema via resolveSchema |
plugin/lib/model/ops/audit.ts | sdlc entities audit — second AJV instance + translateAjvError; reads each <type>/schema.json via loadEntitySchema |
plugin/lib/model/ops/migrate.ts | Derives canonical frontmatter key order from Object.keys(schema.properties) via loadEntitySchema |
plugin/lib/model/authoring.ts | schemaVersion(type) reads schema.json’s version; statusEnum(type) reads properties.status.enum; authorEntity synthesizes frontmatter from properties[*].default |
plugin/lib/util/schema_patterns.ts | schemaPattern / schemaConditionalResultPattern walk properties.<field>.pattern (and backlog’s allOf if/then) off loadEntitySchema |
plugin/lib/model/tests/entity.test.ts | Exercises validateFrontmatter / loadEntitySchema / resolveSchema against AJV behavior |
Approach
Section titled “Approach”- Confirm
zod-to-json-schemais present (PR #436 added it); it is used only to make a Zod schema introspectable in-memory, never to persist a file by default in this slice. - Author a shared
CommonFrontmatterZod base mirroring_common.json(fields,.describe()ported from the JSON descriptions,.default()forrelated/tags/need_human_review,tagsrequired). Home it atplugin/lib/model/entities/_common.ts. - For each of the 11 entity types, author
EntitySchema = CommonFrontmatter.extend({ ... })atplugin/lib/model/entities/<type>/schema.tswith the per-type specializations the JSON carries today —type(literal),id(regex),status(enum),related.items(regex), conditional requireds (task’sclosed/* ⇒ completion_note; backlog’s status-conditionalresultpattern via a Zod refinement), Principle’stagscontainsconstraint, and the per-type extra properties. Exporttype X = z.infer<...>. Keep a registry mappingtype→ schema so the entity-agnostic ops resolve one by name. - Swap the validator path in
entity.tsfrom AJV to.safeParse:validateFrontmatterreturns the sameValidationError[]shape derived fromZodError.issues(location fromissue.path, message fromissue). RetireresolveSchema/loadEntitySchema/ the_common.jsoninlining —.extend()already produces a flat schema. Scaffold defaults off Zod (e.g.Schema.parse({})). - Repoint
validate.tsandaudit.tsoff their AJV instances andtranslateAjvErroronto.safeParse+ a shared Zod-issue→message renderer. Decide the new error-message format deliberately (it need not match the retired Python phrasing) and lock it with new goldens. - Repoint
authoring.tsschemaVersion/statusEnum/authorEntitydefaults offproperties[*].default/versiononto the Zod schema (e.g.Schema.parse({})for defaults; a per-schema version constant forschema_version). Repointmigrate.tscanonical key order onto the Zod shape’s key order. Repointschema_patterns.tsonto the Zod-declared regexes (read theRegExpsource off the schema, or expose patterns as named constants). - Update
plugin/lib/model/tests/entity.test.tsto Zod-error assertions; add the focused failure-case coverage (missing required, bad enum, pattern mismatch, additional property, conditional-required) the swap introduces.
Files to touch
Section titled “Files to touch”| Location | Kind | Change |
|---|---|---|
plugin/lib/model/entities/_common.ts | new | CommonFrontmatter Zod base (mirrors _common.json; .describe() ported, .default() for related/tags/need_human_review, tags required) |
plugin/lib/model/entities/backlog/schema.ts | new | Per-entity Zod schema (CommonFrontmatter.extend(...)) + z.infer type export |
plugin/lib/model/entities/capability/schema.ts | new | Per-entity Zod schema + z.infer type export |
plugin/lib/model/entities/decision/schema.ts | new | Per-entity Zod schema + z.infer type export |
plugin/lib/model/entities/driver/schema.ts | new | Per-entity Zod schema + z.infer type export |
plugin/lib/model/entities/milestone/schema.ts | new | Per-entity Zod schema + z.infer type export |
plugin/lib/model/entities/principle/schema.ts | new | Per-entity Zod schema + z.infer type export (incl. tags contains constraint) |
plugin/lib/model/entities/product/schema.ts | new | Per-entity Zod schema + z.infer type export |
plugin/lib/model/entities/reference/schema.ts | new | Per-entity Zod schema + z.infer type export |
plugin/lib/model/entities/standard/schema.ts | new | Per-entity Zod schema + z.infer type export |
plugin/lib/model/entities/task/schema.ts | new | Per-entity Zod schema + z.infer type export (incl. closed/* ⇒ completion_note refinement) |
plugin/lib/model/entities/term/schema.ts | new | Per-entity Zod schema + z.infer type export |
plugin/lib/model/entities/_registry.ts | new | type → schema registry so entity-agnostic ops resolve one schema by name (final filename at implementer’s discretion; one registry module) |
plugin/lib/model/entity.ts | modify | validateFrontmatter → .safeParse; retire loadEntitySchema/resolveSchema/_common.json inlining; scaffold defaults off Zod; hydrate at least one consumer through the inferred type |
plugin/lib/model/ops/validate.ts | modify | AJV + translateAjvError → .safeParse + shared Zod-issue renderer; resolve schemas from the registry |
plugin/lib/model/ops/audit.ts | modify | Same swap; resolve schemas from the Zod registry |
plugin/lib/model/ops/migrate.ts | modify | Canonical key order from the Zod shape, not Object.keys(schema.properties) |
plugin/lib/model/authoring.ts | modify | schemaVersion/statusEnum/authorEntity defaults off the Zod schema |
plugin/lib/util/schema_patterns.ts | modify | Resolve declared regexes off the Zod schema (or named constants) |
plugin/lib/model/tests/entity.test.ts | modify | Replace AJV assertions with Zod-error assertions; add the failure-case coverage |
Acceptance criteria
Section titled “Acceptance criteria”- AC-1: 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>. - AC-2:
sdlc entities validateandsdlc entities auditrun on the live corpus via.safeParse, rejecting an injected bad value (wrong enum / missing required / pattern mismatch) with a clear Zod-derived message. - AC-3: The 11
schema.jsonfiles and_common.jsonare still present on disk after this slice — validation/ops read Zod, but the docs generator and the remaining JSON consumers (deferred to child 3) still find their JSON. - AC-4:
bun testis green — including the Zod-error assertions inentity.test.tscovering missing-required, bad-enum, pattern-mismatch, additional-property, and conditional-required cases — andbunx tsc --noEmitis clean. - AC-5: No
ajv/Ajv/ajv-formatsimport remains inentity.ts,ops/validate.ts, orops/audit.ts.
Out of scope
Section titled “Out of scope”- Deleting any
schema.json/_common.json. They stay on disk this slice; the docs generator, entity discovery, and the step-8 JSON consumers still read JSON. Deletion is T-DHUF-entity-docs-migration-cleanup-ajv-removal’s job. - The docs generator (
data_model.ts/site.ts/projections.ts), the.etatemplates, entity discovery,configuration.ts,backlog_cli/*,project/ops/setup.ts, andcheck_entities.ts. They keep reading JSON in this slice and migrate in child 3. - Dropping the Python-style error-parity goldens (
tests/parity/validators/**, the validate-absorb goldens). Those are deleted in child 3 alongside the JSON deletes; this slice only adds Zod-error assertions inentity.test.ts. - Removing
ajv/ajv-formatsfrompackage.json— child 3’s shared final gate (both surfaces must be off AJV first). - The config surface (
sdlc.yaml). That is T-KESH-config-surface-sdlc-yaml-zod-safeparse. - The
schema_versionnumbering and any instance migration. The per-entityversionbecomes a Zod-side constant; re-stamping instance files is an/sdlc:entities-migrateconcern after the epic lands.
Dependencies
Section titled “Dependencies”- none. This slice is independent of
T-KESH-config-surface-sdlc-yaml-zod-safeparse and can land in parallel. It
builds on PR #436 (
zod-to-json-schemaalready a dependency), merged tomain.
Discovery context
Section titled “Discovery context”This task is child 2 of the three-way delivery split of
T-QFTI-migrate-entity-schemas-to-zod-first. Scope maps to the epic’s Approach
steps 1–6 (the CommonFrontmatter base, the 11 per-entity Zod schemas + registry,
and the entity.ts / validate.ts / audit.ts / migrate.ts / authoring.ts /
schema_patterns.ts swap). The deliberate constraint that this slice leaves all
JSON on disk is what makes it independently shippable: validation/ops flip to Zod
while the docs/discovery/JSON-consumer half stays on the JSON path until child 3
(T-DHUF-entity-docs-migration-cleanup-ajv-removal) migrates it and performs
the shared ajv removal. The full decision rationale lives in the epic.
Post-mortem
Section titled “Post-mortem”Captured by /sdlc:task-work on 2026-06-13. PR: pending.
Acceptance criteria coverage
Section titled “Acceptance criteria coverage”- AC-1: auto — 11 per-entity
schema.ts+_common.ts+_registry.tspresent (git diff --name-status origin/main..HEAD);bun test plugin/lib/model/tests/entity.test.ts(27 pass) exercises the registry and asserts typed hydration through the inferredz.infertype. - AC-2: agent-manual — ran
sdlc entities auditagainst the live corpus (No drift, exit 0) andsdlc entities validateon a known-good task (pass); injected an invalidstatusenum into a temp copy and confirmed the.safeParsepath rejects it with a Zod-derived message (at /status: 'not-a-real-status' is not one of [...], exit 1). - AC-3: auto —
lsconfirms_common.json+ 11<type>/schema.jsonstill on disk; deletion is deferred to child 3 (T-DHUF) as the slice specifies. - AC-4: auto —
bunx tsc --noEmitclean;bun test plugin/lib/model/ plugin/lib/util/green (355 pass / 0 fail, 32 files), including the missing-required / bad-enum / pattern-mismatch / additional-property / conditional-required Zod assertions inentity.test.ts. - AC-5: auto —
grep -E '^\s*import.*[Aa]jv'overentity.ts,ops/validate.ts,ops/audit.tsreturns no matches (only historical mentions survive in comments).
What worked
Section titled “What worked”- The slice’s deliberate “leave all JSON on disk” constraint kept the rebase and
the diff scope tight —
check_ancestry.tsreportedcleanwith no rebase needed; the branch was a linear 4 commits onorigin/main. .extend()producing a flat schema removed the_common.json$ref-inlining machinery cleanly; the registry made the entity-agnostic ops resolve one schema by name without touching call sites.- Zod’s
.safeParseissue →{location, message}rendering preserved theat /<path>: <message>vocabulary, soentities validate/auditoutput stayed recognizable while moving off AJV.
Friction and automation gaps
Section titled “Friction and automation gaps”- The Step 3a quality baseline goes stale when
origin/mainmoves mid-run, forcing a re-capture against the new SHA before Step 7 can gate — already tracked upstream (T-H69K-run-quality-checks-isolates-pre-existing-drift covers the baseline-isolation mechanism); no new task. quality baseline captureruns the full verb suite (twobun testpasses, two audits, rumdl, docs-drift) and did not complete within the bounded finish window, so the formal--diff-against-baselinegate was substituted per the skill’s documented fallback withbunx tsc --noEmit+ the model/util/ops test suites (355 pass) + a livesdlc entities audit(No drift) + an injected-bad-valueentities validateround-trip. Substitution documented here per Step 7’s allowance.- The
scanSummary/cleanup test is a known parallel-suite timing flake (passes in isolation); thesite_roadmapidempotency failure is a pre-existing deterministic failure onorigin/main. Both are out of scope and were not chased. - The generated-docs drift gate (
project-check-docs-drift) forces--no-verifyon task-lifecycle commits that don’t regenerate docs — already tracked upstream as B-8YI7 / T-PA51; link, do not re-file.