Fix TypeBox schema consistency and add TypeBox patterns research

- schemas.md: Replace interface ResolvedTaskAttributes with TypeBox schema
  + Static<typeof> derivation (was the only raw interface in the doc set)
- schemas.md: Add explicit TypeBox naming convention table and pattern guide
- schemas.md: Use Nullable() helper for TaskInput optional categorical fields
  that can be explicitly set to null in YAML
- schemas.md: Reference typebox-patterns.md research for full analysis
- api-surface.md: Add note about Static<typeof> pattern consistency
- errors-validation.md: Use Value.Errors() for structured validation instead
  of bare Value.Check()
- New: docs/research/typebox-patterns.md — comprehensive TypeBox pattern
  evaluation covering Static, Values, Convert, Pointer, TemplateLiteral,
  generics, defaults, and concrete schema recommendations
This commit is contained in:
2026-04-26 07:57:23 +00:00
parent 235328813e
commit ac9dee9c10
4 changed files with 1076 additions and 39 deletions

View File

@@ -107,7 +107,7 @@ function calculateTaskEv(p: number, scopeCost: number, impactWeight: number, con
## Return Types
All return types are defined as TypeBox schemas (for runtime validation + JSON Schema export) with corresponding static TypeScript types.
All return types are defined as TypeBox schemas (for runtime validation + JSON Schema export). The corresponding TypeScript types are derived via `Static<typeof Schema>` — no separate `interface` or `type` definitions. See [schemas.md](schemas.md) for the full naming convention.
### RiskPathResult

View File

@@ -65,7 +65,7 @@ Two validation levels, consistent with the Rust CLI's `validate` command:
### 1. Schema validation (`validateSchema()`)
TypeBox `Value.Check` on input data — frontmatter fields, enum values, required fields. Returns `ValidationError[]`. Catches:
TypeBox `Value.Check()` on input data — frontmatter fields, enum values, required fields. Returns `ValidationError[]`. The validation pipeline uses `Value.Errors()` for structured field-level detail (path, message, value) rather than `Value.Assert()` which throws without detail. `Value.Clean()` strips unknown properties before validation when processing untrusted input (e.g., frontmatter from external files). Catches:
- Missing required fields (`id`, `name`)
- Invalid enum values (e.g., `risk: "extreme"`)
- Type mismatches (e.g., `dependsOn: "not-an-array"`)

View File

@@ -11,37 +11,62 @@ TypeBox schema definitions, categorical enums, and their numeric methods.
All data shapes are defined as TypeBox schemas. This gives us:
1. **Static TypeScript types** via `Static<typeof Schema>`compile-time safety
2. **Runtime validation** via `Value.Check()` / `Value.Assert()`reject bad input before it hits the graph
1. **Static TypeScript types** via `Static<typeof Schema>`every schema constant has a corresponding `type X = Static<typeof X>` alias. The schema is the source of truth; the type is derived from it. No separate `interface` or `type` definitions outside of `Static<typeof>`.
2. **Runtime validation** via `Value.Check()` / `Value.Errors()`structured field-level error reporting with path, message, and value. Used for input validation before graph construction.
3. **JSON Schema** for free — can be used by consumers for their own validation, API contracts, etc.
The TypeBox schemas serve as the single source of truth for both types and validation. No separate type definitions, no Zod, no ad-hoc validation logic. Consumers with Zod in their stack can convert at their boundary.
### Naming Convention
| Category | Convention | Example |
|----------|-----------|---------|
| Enum schema constant | PascalCase + `Enum` suffix | `TaskStatusEnum` |
| Enum type alias | PascalCase, no suffix | `type TaskStatus = Static<typeof TaskStatusEnum>` |
| Object schema constant | PascalCase, no suffix | `TaskInput`, `ResolvedTaskAttributes` |
| Object type alias | Same name as schema constant (TypeScript resolves by context) | `type TaskInput = Static<typeof TaskInput>` |
| Function signatures | Use compile-time type aliases | `function riskSuccessProbability(risk: TaskRisk): number` |
**Rule**: Schema constant = runtime value (has `Enum` suffix only for enums). Type alias = compile-time type (never has `Enum` suffix). Function signatures always use the type alias.
### TypeBox Patterns Used
- `Type.Union([Type.Literal(...)])` for categorical enums — the idiomatic pattern for finite string unions
- `Type.Optional()` for nullable-optional fields
- `Static<typeof Schema>` for deriving all TypeScript types — never hand-write `interface` or `type` for schemas
- `Value.Check()` + `Value.Errors()` for structured validation (not `Value.Assert()` which throws without field-level detail)
- `Value.Clean()` for sanitizing untrusted input (strips unknown properties)
- `Type.Partial()` for deriving update types (e.g., `TaskGraphNodeAttributesUpdate = Type.Partial(TaskGraphNodeAttributes)`)
> See [docs/research/typebox-patterns.md](../research/typebox-patterns.md) for the full analysis of TypeBox patterns evaluated and adoption/skip decisions.
## Input Schemas
### TaskInput
The universal input shape for a task, matching the Rust `TaskFrontmatter` field set:
The universal input shape for a task, matching the Rust `TaskFrontmatter` field set. Note the use of `Type.Optional(Nullable(...))` for categorical fields — this makes the field itself optional at the object level AND nullable when present. YAML frontmatter distinguishes between "key absent" and "key set to null" (e.g., `risk:` with no value), so we need both.
```typescript
const TaskInput = Type.Object({
id: Type.String(),
name: Type.String(),
dependsOn: Type.Array(Type.String()),
status: Type.Optional(TaskStatusEnum),
scope: Type.Optional(TaskScopeEnum),
risk: Type.Optional(TaskRiskEnum),
impact: Type.Optional(TaskImpactEnum),
level: Type.Optional(TaskLevelEnum),
priority: Type.Optional(TaskPriorityEnum),
status: Type.Optional(Nullable(TaskStatusEnum)),
scope: Type.Optional(Nullable(TaskScopeEnum)),
risk: Type.Optional(Nullable(TaskRiskEnum)),
impact: Type.Optional(Nullable(TaskImpactEnum)),
level: Type.Optional(Nullable(TaskLevelEnum)),
priority: Type.Optional(Nullable(TaskPriorityEnum)),
tags: Type.Optional(Type.Array(Type.String())),
assignee: Type.Optional(Type.String()),
due: Type.Optional(Type.String()),
created: Type.Optional(Type.String()),
modified: Type.Optional(Type.String()),
assignee: Type.Optional(Nullable(Type.String())),
due: Type.Optional(Nullable(Type.String())),
created: Type.Optional(Nullable(Type.String())),
modified: Type.Optional(Nullable(Type.String())),
})
```
Where `Nullable = <T extends TSchema>(T: T) => Type.Union([T, Type.Null()])`.
### DependencyEdge
```typescript
@@ -102,16 +127,23 @@ This validates the graphology `export()` output and enables `import()` from vali
Categorical enums are defined with `Type.Union(Type.Literal(...))` — string values matching the DB and frontmatter conventions.
**Naming convention**: The TypeBox schema constants use an `Enum` suffix (e.g., `TaskScopeEnum`, `TaskRiskEnum`). The corresponding TypeScript type aliases drop the suffix (e.g., `type TaskScope = Static<typeof TaskScopeEnum>`). The schema constant is the runtime value; the type alias is the compile-time type. All function signatures use the compile-time type names.
```typescript
const TaskScopeEnum = Type.Union([
Type.Literal("single"), Type.Literal("narrow"),
Type.Literal("moderate"), Type.Literal("broad"), Type.Literal("system"),
])
type TaskScope = Static<typeof TaskScopeEnum>
| Enum Schema Constant | TypeScript Type | Values |
|----------------------|-----------------|--------|
| `TaskScopeEnum` | `TaskScope` | single, narrow, moderate, broad, system |
| `TaskRiskEnum` | `TaskRisk` | trivial, low, medium, high, critical |
| `TaskImpactEnum` | `TaskImpact` | isolated, component, phase, project |
| `TaskLevelEnum` | `TaskLevel` | planning, decomposition, implementation, review, research |
| `TaskPriorityEnum` | `TaskPriority` | low, medium, high, critical |
| `TaskStatusEnum` | `TaskStatus` | pending, in-progress, completed, failed, blocked |
const TaskRiskEnum = Type.Union([
Type.Literal("trivial"), Type.Literal("low"),
Type.Literal("medium"), Type.Literal("high"), Type.Literal("critical"),
])
type TaskRisk = Static<typeof TaskRiskEnum>
// ... same pattern for TaskImpact, TaskLevel, TaskPriority, TaskStatus
```
See the naming convention table in "Design Decision: TypeBox as Single Source of Truth" above for the `Enum` suffix rule.
### Numeric Methods
@@ -163,26 +195,31 @@ function resolveDefaults(attrs: Partial<TaskGraphNodeAttributes>): ResolvedTaskA
## ResolvedTaskAttributes
The output of `resolveDefaults` — all categorical fields resolved to their numeric equivalents for use in analysis:
The output of `resolveDefaults` — all categorical fields resolved to their numeric equivalents for use in analysis. Defined as a TypeBox schema (not a raw `interface`) so that `Static<typeof ResolvedTaskAttributes>` derives the TypeScript type:
```typescript
interface ResolvedTaskAttributes {
name: string
scope: TaskScope
risk: TaskRisk
impact: TaskImpact
level: TaskLevel | null
priority: TaskPriority | null
status: TaskStatus | null
const ResolvedTaskAttributes = Type.Object({
name: Type.String(),
scope: TaskScopeEnum, // resolved from default
risk: TaskRiskEnum, // resolved from default
impact: TaskImpactEnum, // resolved from default
level: Nullable(TaskLevelEnum), // nullable — label-only
priority: Nullable(TaskPriorityEnum), // nullable — label-only
status: Nullable(TaskStatusEnum), // nullable — label-only
// Numeric equivalents (always present after resolution):
costEstimate: number
tokenEstimate: number
successProbability: number
riskWeight: number
impactWeight: number
}
costEstimate: Type.Number(),
tokenEstimate: Type.Number(),
successProbability: Type.Number(),
riskWeight: Type.Number(),
impactWeight: Type.Number(),
})
type ResolvedTaskAttributes = Static<typeof ResolvedTaskAttributes>
```
Where `Nullable` is a generic helper: `const Nullable = <T extends TSchema>(T: T) => Type.Union([T, Type.Null()])`
Note how categorical fields that have defaults (`scope`, `risk`, `impact`) are no longer optional — `resolveDefaults` fills them in. Label-only fields (`level`, `priority`, `status`) remain nullable since they have no meaningful default.
**Why `level`, `priority`, and `status` remain nullable**: These three fields are label-only enums with no numeric methods (see "Label-only enums" above). They are used for filtering and labeling, not for cost calculations. A task with `level: null` simply hasn't been categorized — the analysis functions don't need a numeric value for it. `risk`, `scope`, and `impact` are the only fields that feed into EV and risk calculations, so they're the only ones that need default resolution.
> **Note on `level`**: While the cost-benefit framework shows that "risk: critical at planning level > risk: critical at implementation level" (upstream failures multiply), this is captured by the DAG-propagation model's topology-aware cost computation, not by a numeric value on `level` itself. The `level` field serves as metadata for filtering and display, not as a cost input.