Skip to content

Schema validation for run-index.json with Zod #35

Description

@williamthorsen

Description

The Factory status-adapter.ts validates run-index.json (v2) and status.json (v1) using ~177 lines of hand-rolled type-guard functions (isRecord, isOptionalString, isValidPhaseStatus, etc.). While this works and has comprehensive test coverage, it has several drawbacks:

  • No reusable schema definition — the shape of run-index.json is documented only implicitly by TypeScript interfaces and validation functions scattered across status-adapter.ts
  • No standalone validation tool — validating run output requires running the full Express server; there is no CLI script to validate files on disk
  • Maintenance burden — every new field requires updating both a TypeScript interface and a corresponding type-guard function, with no compile-time guarantee they stay in sync
  • No structured error reporting — validation throws a generic "Invalid run-index.json at {path}" with no detail on which field failed

This ticket introduces a schema-validation library, defines schemas for the run-index.json data structure, creates a standalone validation script, and refactors the status-adapter to use the schemas.

Proposed library: Zod

Zod is the proposed library:

  1. TanStack Router compatibility — TanStack Router (anticipated addition to Factory) uses Zod for route-param validation out of the box
  2. TypeScript-first — schemas infer TypeScript types directly via z.infer<>, eliminating duplication between interfaces and validation functions
  3. Rich error reportingZodError provides structured, per-field error paths, messages, and codes
  4. Ecosystem adoption — widely used with React Hook Form, tRPC, and other tools in the TypeScript ecosystem
  5. Small footprint — tree-shakeable, no runtime dependencies

Alternatives considered

Library Pros Cons
Valibot Smaller bundle (~1kB), similar API Less ecosystem integration, no TanStack Router synergy
AJV + JSON Schema Industry-standard, language-agnostic Requires separate type generation, verbose, heavier runtime
Yup Mature, well-known Weaker TypeScript integration, larger bundle, declining momentum

If the team decides against Zod, Valibot is the closest alternative.

Architecture context

Data flow

Disk: run-index.json (v2) / status.json (v1)
  → status-adapter.ts: parseRunData() reads + validates + normalizes
    → CanonicalRunStatus (shared/types/canonical.ts)
      → project-scanner.ts: discovers runs, extracts status/startedAt
      → API routes → client

Key files

File Role
packages/factory/src/server/adapters/status-adapter.ts Validation + normalization (~320 lines, ~177 lines are type guards)
packages/factory/src/server/adapters/__tests__/status-adapter.test.ts Comprehensive validation tests (~900 lines)
packages/factory/src/shared/types/canonical.ts Domain model: CanonicalRunStatus, Phases, ArtifactEntry, etc. (18 exported types)
packages/factory/src/server/services/project-scanner.ts Consumes parseRunData()
packages/factory/src/__test-helpers__/fixtures.ts createMockRunStatus(), emptyPhases(), createCompletedRunPhases()

V2 run-index.json structure

{
  "version": 2,
  "context": {
    "runId": "string",           // required
    "projectSlug": "string",     // required
    "ticketId": "string?",       // optional
    "projectRoot": "string",     // required
    "branch": "string",          // required
    "task": "string",            // required
    "startedAt": "string",       // required (ISO datetime)
    "completedAt": "string?",    // optional, nullable
    "status": "RunStatus",       // enum: in_progress | completed | failed | needs_manual_review
    "phases": {                  // each value is nullable (null = not yet reached)
      "architecture": { "status": "PhaseStatus", "impactLevel?": "string", "artifact?": "string" },
      "planning": { "status": "PhaseStatus", "stepCount?": "number", "artifacts?": ["string"] },
      "implementation": { "status": "PhaseStatus", "artifact?": "string", "qualityGates?": "..." },
      "parallelReview": { "aggregatedCriticality": "Criticality", "reviewRoundsUsed": "number", "..." },
      "review": { "status": "PhaseStatus", "iterations?": "number", "finalCriticality?": "Criticality" },
      "codeSimplifier": { "ran": "boolean", "actionableFindings": "boolean", "..." },
      "holisticReview": { "status": "PhaseStatus", "criticality?": "Criticality", "..." }
    },
    "phaseDecisions?": { "[phase]": { "run": "boolean", "reason?": "string" } }
  },
  "config": {
    "externalPlan?": "boolean",
    "mergeBaseSha?": "string",
    "diffBase?": "string",
    "maxReviewRounds?": "number",
    "fixLowFindings?": "boolean",
    "mode?": "string",
    "model?": "string"
  },
  "artifacts?": [
    {
      "filename": "string",      // required
      "role": "string",          // required
      "roleType": "string",      // required
      "agent": "string",         // required
      "type": "string",          // required
      "phase": "string",         // required
      "createdAt": "string",     // required
      "iteration?": "number",    // optional
      "note?": "string"          // optional
    }
  ]
}

Enum values

  • RunStatus: in_progress, completed, failed, needs_manual_review
  • PhaseStatus: completed, skipped, failed, in_progress, approved
  • Criticality: none, low, medium, high
  • ReviewerStatus: completed, skipped, failed

Validation behaviors to preserve

These behaviors are verified by the existing 900-line test suite:

  • null phase entries are valid (phase not yet reached)
  • completedAt accepts null, undefined, or string
  • phases object may contain unknown phase keys (forward-compatible)
  • Bare {} phase entries are accepted
  • phaseDecisions entries require run: boolean; reason is optional
  • Phase entry fields status, criticality, finalCriticality, aggregatedCriticality accept null or their respective enum values
  • Artifact entries require all 7 base fields; iteration and note are optional

Run-index.json file locations

Run-index.json files are produced by the orchestrated-development pipeline and stored at:

~/.ai/projects/{projectSlug}/tickets/{ticketId}/{runId}/run-index.json

Key design decisions

  1. Schemas live in server/adapters/schemas/ — V2RunIndex and V1StatusObject describe on-disk formats read only by the server. Client code never touches raw formats.

  2. canonical.ts types stay hand-writtenCanonicalRunStatus is a flat domain model; V2RunIndex is nested (context + config). The normalizeV2/normalizeV1 functions map between these shapes. Replacing canonical types with z.infer<> would require complex transforms and touch 15+ importing files. Additionally, exactOptionalPropertyTypes is enabled — ArtifactEntry uses iteration?: number (property can be absent), which differs from Zod's .optional() producing T | undefined (must be present).

  3. Phase entries use .passthrough() for forward-compatibility — The current validation allows unknown keys in phase objects and only validates known ones. A z.object({...}).passthrough() preserves this.

  4. Zod is a runtime dependency — Schemas are used at runtime in the server adapter, not just at build time. However, since schemas are in server/adapters/, they are not included in the client bundle (Vite tree-shakes them).

Acceptance criteria

Must have

  • Add zod as a runtime dependency in packages/factory/ with exact version pinning
  • Create V2 run-index.json Zod schema defining all structures (RunIndex, Context, Config, Phases, ArtifactEntry, enums)
  • Create V1 status.json Zod schema reusing shared enum/phase schemas
  • Schema tests covering all enum values, optional/nullable fields, forward-compatible phase entries, and meaningful Zod error paths
  • Refactor status-adapter.ts to replace hand-rolled type guards with Zod .safeParse() — preserve public API and error messages
  • All existing status-adapter tests pass unchanged
  • Create CLI validation script that validates single files or scans directories, reports per-field errors, and exits non-zero on failure
  • Register script as pnpm run validate:run-index in factory package.json
  • CLI script tests

Should have

  • Summary output when scanning directories (total files, passed, failed)

Nice to have

  • JSON Schema export from Zod schemas for language-agnostic documentation
  • Integration with project-scanner.ts to surface structured validation errors instead of silently skipping invalid runs

Metadata

Metadata

Labels

featureAdded or improved external functionalityscope:factory

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions