Skip to content

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.

LocationRole today
plugin/lib/model/entities/_common.jsonShared 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.tsThe 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.tssdlc entities validate — own AJV instance + translateAjvError (Python-jsonschema-style messages); resolves explicit --schema via resolveSchema
plugin/lib/model/ops/audit.tssdlc entities audit — second AJV instance + translateAjvError; reads each <type>/schema.json via loadEntitySchema
plugin/lib/model/ops/migrate.tsDerives canonical frontmatter key order from Object.keys(schema.properties) via loadEntitySchema
plugin/lib/model/authoring.tsschemaVersion(type) reads schema.json’s version; statusEnum(type) reads properties.status.enum; authorEntity synthesizes frontmatter from properties[*].default
plugin/lib/util/schema_patterns.tsschemaPattern / schemaConditionalResultPattern walk properties.<field>.pattern (and backlog’s allOf if/then) off loadEntitySchema
plugin/lib/model/tests/entity.test.tsExercises validateFrontmatter / loadEntitySchema / resolveSchema against AJV behavior
  1. Confirm zod-to-json-schema is 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.
  2. Author a shared CommonFrontmatter Zod base mirroring _common.json (fields, .describe() ported from the JSON descriptions, .default() for related/tags/need_human_review, tags required). Home it at plugin/lib/model/entities/_common.ts.
  3. For each of the 11 entity types, author EntitySchema = CommonFrontmatter.extend({ ... }) at plugin/lib/model/entities/<type>/schema.ts with the per-type specializations the JSON carries today — type (literal), id (regex), status (enum), related.items (regex), conditional requireds (task’s closed/* ⇒ completion_note; backlog’s status-conditional result pattern via a Zod refinement), Principle’s tags contains constraint, and the per-type extra properties. Export type X = z.infer<...>. Keep a registry mapping type → schema so the entity-agnostic ops resolve one by name.
  4. Swap the validator path in entity.ts from AJV to .safeParse: validateFrontmatter returns the same ValidationError[] shape derived from ZodError.issues (location from issue.path, message from issue). Retire resolveSchema / loadEntitySchema / the _common.json inlining — .extend() already produces a flat schema. Scaffold defaults off Zod (e.g. Schema.parse({})).
  5. Repoint validate.ts and audit.ts off their AJV instances and translateAjvError onto .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.
  6. Repoint authoring.ts schemaVersion / statusEnum / authorEntity defaults off properties[*].default / version onto the Zod schema (e.g. Schema.parse({}) for defaults; a per-schema version constant for schema_version). Repoint migrate.ts canonical key order onto the Zod shape’s key order. Repoint schema_patterns.ts onto the Zod-declared regexes (read the RegExp source off the schema, or expose patterns as named constants).
  7. Update plugin/lib/model/tests/entity.test.ts to Zod-error assertions; add the focused failure-case coverage (missing required, bad enum, pattern mismatch, additional property, conditional-required) the swap introduces.
LocationKindChange
plugin/lib/model/entities/_common.tsnewCommonFrontmatter Zod base (mirrors _common.json; .describe() ported, .default() for related/tags/need_human_review, tags required)
plugin/lib/model/entities/backlog/schema.tsnewPer-entity Zod schema (CommonFrontmatter.extend(...)) + z.infer type export
plugin/lib/model/entities/capability/schema.tsnewPer-entity Zod schema + z.infer type export
plugin/lib/model/entities/decision/schema.tsnewPer-entity Zod schema + z.infer type export
plugin/lib/model/entities/driver/schema.tsnewPer-entity Zod schema + z.infer type export
plugin/lib/model/entities/milestone/schema.tsnewPer-entity Zod schema + z.infer type export
plugin/lib/model/entities/principle/schema.tsnewPer-entity Zod schema + z.infer type export (incl. tags contains constraint)
plugin/lib/model/entities/product/schema.tsnewPer-entity Zod schema + z.infer type export
plugin/lib/model/entities/reference/schema.tsnewPer-entity Zod schema + z.infer type export
plugin/lib/model/entities/standard/schema.tsnewPer-entity Zod schema + z.infer type export
plugin/lib/model/entities/task/schema.tsnewPer-entity Zod schema + z.infer type export (incl. closed/* ⇒ completion_note refinement)
plugin/lib/model/entities/term/schema.tsnewPer-entity Zod schema + z.infer type export
plugin/lib/model/entities/_registry.tsnewtype → schema registry so entity-agnostic ops resolve one schema by name (final filename at implementer’s discretion; one registry module)
plugin/lib/model/entity.tsmodifyvalidateFrontmatter.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.tsmodifyAJV + translateAjvError.safeParse + shared Zod-issue renderer; resolve schemas from the registry
plugin/lib/model/ops/audit.tsmodifySame swap; resolve schemas from the Zod registry
plugin/lib/model/ops/migrate.tsmodifyCanonical key order from the Zod shape, not Object.keys(schema.properties)
plugin/lib/model/authoring.tsmodifyschemaVersion/statusEnum/authorEntity defaults off the Zod schema
plugin/lib/util/schema_patterns.tsmodifyResolve declared regexes off the Zod schema (or named constants)
plugin/lib/model/tests/entity.test.tsmodifyReplace AJV assertions with Zod-error assertions; add the failure-case coverage
  • AC-1: 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>.
  • AC-2: sdlc entities validate and sdlc entities audit run 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.json files and _common.json are 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 test is green — including the Zod-error assertions in entity.test.ts covering missing-required, bad-enum, pattern-mismatch, additional-property, and conditional-required cases — and bunx tsc --noEmit is clean.
  • AC-5: No ajv / Ajv / ajv-formats import remains in entity.ts, ops/validate.ts, or ops/audit.ts.
  • 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 .eta templates, entity discovery, configuration.ts, backlog_cli/*, project/ops/setup.ts, and check_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 in entity.test.ts.
  • Removing ajv / ajv-formats from package.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_version numbering and any instance migration. The per-entity version becomes a Zod-side constant; re-stamping instance files is an /sdlc:entities-migrate concern after the epic lands.

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.

Captured by /sdlc:task-work on 2026-06-13. PR: pending.

  • AC-1: auto — 11 per-entity schema.ts + _common.ts + _registry.ts present (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 inferred z.infer type.
  • AC-2: agent-manual — ran sdlc entities audit against the live corpus (No drift, exit 0) and sdlc entities validate on a known-good task (pass); injected an invalid status enum into a temp copy and confirmed the .safeParse path rejects it with a Zod-derived message (at /status: 'not-a-real-status' is not one of [...], exit 1).
  • AC-3: auto — ls confirms _common.json + 11 <type>/schema.json still on disk; deletion is deferred to child 3 (T-DHUF) as the slice specifies.
  • AC-4: auto — bunx tsc --noEmit clean; 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 in entity.test.ts.
  • AC-5: auto — grep -E '^\s*import.*[Aa]jv' over entity.ts, ops/validate.ts, ops/audit.ts returns no matches (only historical mentions survive in comments).
  • The slice’s deliberate “leave all JSON on disk” constraint kept the rebase and the diff scope tight — check_ancestry.ts reported clean with no rebase needed; the branch was a linear 4 commits on origin/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 .safeParse issue → {location, message} rendering preserved the at /<path>: <message> vocabulary, so entities validate/audit output stayed recognizable while moving off AJV.
  • The Step 3a quality baseline goes stale when origin/main moves 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 capture runs the full verb suite (two bun test passes, two audits, rumdl, docs-drift) and did not complete within the bounded finish window, so the formal --diff-against-baseline gate was substituted per the skill’s documented fallback with bunx tsc --noEmit + the model/util/ops test suites (355 pass) + a live sdlc entities audit (No drift) + an injected-bad-value entities validate round-trip. Substitution documented here per Step 7’s allowance.
  • The scanSummary/cleanup test is a known parallel-suite timing flake (passes in isolation); the site_roadmap idempotency failure is a pre-existing deterministic failure on origin/main. Both are out of scope and were not chased.
  • The generated-docs drift gate (project-check-docs-drift) forces --no-verify on task-lifecycle commits that don’t regenerate docs — already tracked upstream as B-8YI7 / T-PA51; link, do not re-file.

← Back to Tasks