Skip to content

T-5WOP-parity-fixture-derived-from-zod

Status: closed/superseded · Impact: medium · Complexity: small

AUTO-DEFINED: this spec was best-effort machine-authored by /sdlc:task-auto-define on 2026-08-03 as part of the spawn flow’s readiness drive. Review the Goal, Approach, Today, Files-to-touch, and Acceptance-criteria carefully before trusting it.

The schema-parity gate has an unguarded edge between the zod schema and the JSON fixture it compares against the ontogen-generated TypeScript types, so a zod-side field addition can leave the fixture stale while the gate stays green. Closing that edge — deriving the fixture from the schema and failing on drift — makes the gate catch the class of miss that T-HTN8-lease-gate-memo-fields hit.

Nothing guards the zod-to-fixture edge of the schema-parity gate: apps/sdlc/desktop/scripts/fixtures/parity/task-lifecycle-lease.schema.json is a hand-maintained projection of TaskLifecycleLeaseSchema, and T-HTN8’s two new fields silently left it stale. The gate only compares that fixture against the generated Rust types, so it stayed green. A check that derives the fixture from the zod schema and fails on drift would have caught it at the gate.

T-HTN8-lease-gate-memo-fields on git@github.com:sksizer/dev.git

LocationRole today
apps/sdlc/lib/services/lease/schemas.ts#TaskLifecycleLeaseSchemaZod source of truth for the task-lifecycle lease payload; new fields land here first.
apps/sdlc/desktop/scripts/fixtures/parity/task-lifecycle-lease.schema.jsonHand-written JSON projection of that zod schema. Its $comment asks a human to re-read schemas.ts when the schema changes; nothing enforces it.
apps/sdlc/desktop/scripts/schema-parity.ts#runParityCompares the checked-in fixture against the generated TypeScript types. It never reads the zod schema, so a stale fixture passes.
apps/sdlc/desktop/scripts/schema-parity.ts#schemaFieldsTreats a fixture field as optional when it is absent from required, matching the TS-side rule that T | null is optional.
apps/sdlc/desktop/scripts/schema-parity.map.jsonMaps the generated type name TaskLifecycleLease to the fixture path.
apps/sdlc/lib/services/kind_render.ts#jsonSchemaForExisting zod-to-JSON-Schema projection (z.toJSONSchema(schema, { io: "input" })) behind sdlc commit get-schema.
.claude/skills/project-check/check_apps_imports.tsRepo-level gate enforcing S-0008-apps-consume-substrate-through-published-surfaces; it is why the fixture is hand-projected instead of imported.
.claude/skills/project-check/check_util_redeclarations.tsRepo-level gate that already carries the --write-baseline regeneration-flag convention this task copies.
lefthook.ymlRuns the project-check-* gates pre-commit.
.github/workflows/sdf-ci.ymlRuns moon run sdf:schema-parity and the parity self-test in CI.

A repo-level checker derives the lease fixture from TaskLifecycleLeaseSchema and exits non-zero when the checked-in JSON no longer matches — naming each field that differs — so a zod-side field addition cannot leave the fixture stale and green. The checker runs pre-commit through lefthook.yml and in sdf-ci.yml, and a --write flag rewrites the fixture so the fix is mechanical rather than hand-edited.

The checker lives under .claude/skills/project-check/, which is repo-level tooling rather than application code, so it may import apps/sdlc/lib/services/lease/schemas.ts directly without weakening S-0008-apps-consume-substrate-through-published-surfaces — the sibling gates there already import the substrate the same way.

  1. Add .claude/skills/project-check/check_parity_fixture_drift.ts. It holds a small in-file binding table — today one entry: the fixture path apps/sdlc/desktop/scripts/fixtures/parity/task-lifecycle-lease.schema.json paired with TaskLifecycleLeaseSchema — so a second lease fixture is one table row, not a rewrite.
  2. Project the bound zod schema with z.toJSONSchema(schema, { io: "input" }), the same call apps/sdlc/lib/services/kind_render.ts#jsonSchemaFor makes.
  3. Reduce the projection to the comparator’s shape: the property-name set, plus a required list that drops nullable and defaulted fields. That is the fixture’s stated convention (expires_at, pr_number, notes are omitted) and it matches how apps/sdlc/desktop/scripts/schema-parity.ts#schemaFields reads required on the other side.
  4. Read the checked-in fixture, diff its property-name set and required set against the derived pair, print one line per difference (field name plus which side declares it), exit 1 when the diff is non-empty and 0 otherwise.
  5. Add a --write flag that rewrites the fixture from the derivation, preserving the $comment header and the file’s key order, following the --write-baseline convention in .claude/skills/project-check/check_util_redeclarations.ts.
  6. Add .claude/skills/project-check/tests/check_parity_fixture_drift.test.ts covering three cases against temp copies: the in-tree pair passes; a fixture missing a zod-declared field fails naming that field; a fixture declaring a field the zod schema does not fails naming that field.
  7. Wire the checker into lefthook.yml as a project-check-parity-fixture entry alongside the other project-check-* runs, and into .github/workflows/sdf-ci.yml next to the existing schema-parity steps.
  8. Rewrite the fixture’s $comment so it states the file is generated by the checker’s --write mode, replacing the current “regenerate by re-reading schemas.ts” instruction.
LocationKindChange
.claude/skills/project-check/check_parity_fixture_drift.tsnewDerives the fixture from the bound zod schema, diffs it against the checked-in JSON, --write regenerates.
.claude/skills/project-check/tests/check_parity_fixture_drift.test.tsnewPasses on the in-tree pair; fails on a seeded missing field and on a seeded extra field.
lefthook.ymlmodifyAdds the project-check-parity-fixture pre-commit entry with a comment explaining the guarded edge.
.github/workflows/sdf-ci.ymlmodifyRuns the checker alongside moon run sdf:schema-parity.
apps/sdlc/desktop/scripts/fixtures/parity/task-lifecycle-lease.schema.jsonmodify$comment rewritten to name the checker and its --write mode as the regeneration path.
  • AC-1: bun run .claude/skills/project-check/check_parity_fixture_drift.ts exits 0 against an unmodified checkout.
  • AC-2: With a field added to TaskLifecycleLeaseSchema and the fixture left untouched, that command exits non-zero and prints the added field’s name.
  • AC-3: With a field deleted from the fixture that the zod schema declares, that command exits non-zero and prints the deleted field’s name.
  • AC-4: After a drift is introduced, --write rewrites the fixture and a following run of the checker exits 0.
  • AC-5: The derived required list omits expires_at, pr_number, and notes, so the regenerated fixture is byte-comparable to the committed one on an unmodified checkout.
  • AC-6: bun test .claude/skills/project-check/tests/check_parity_fixture_drift.test.ts passes.
  • AC-7: lefthook.yml and .github/workflows/sdf-ci.yml each invoke the checker.
  • A general sdlc lease get-schema <kind> verb mirroring sdlc commit get-schema. Worth adding when a consumer outside repo-level tooling needs the lease projection; the checker here imports the schema directly and does not need it.
  • The other lease payload schemas (operation lease, control-plane tooling). They have no parity fixture today, so there is no edge to guard.
  • Widening what the parity comparator checks (types, enums, string formats). This task guards field names and optionality only, matching what apps/sdlc/desktop/scripts/schema-parity.ts#compareShapes already compares.
  • none. T-HTN8-lease-gate-memo-fields is the originating task, not a blocker: this checker lands independently and will flag the fixture if that work merges without regenerating it.

Spawned by /sdlc:spawn-task-pr on 2026-08-03 UTC from T-HTN8-lease-gate-memo-fields in git@github.com:sksizer/dev.git.


← Back to Tasks