Summary
Add optional runtime output validation to flow handlers using the Standard Schema interface.
Proposed DSL:
.step({ outputSchema }) validates the handler's complete output.
.array({ outputSchema }) validates the complete returned array.
.map({ itemOutputSchema }) validates each map task's returned item before aggregation.
onOutputInvalid: 'retry' | 'fail' controls whether a schema violation uses normal retry handling or immediately hard-fails the step/run. Default: 'retry'.
Schemas stay in the runtime flow definition. They are not converted to JSON Schema, compiled into migrations, persisted in Postgres, or included in flow-shape comparison.
Flow input validation is intentionally out of scope for the initial implementation. Users can model it explicitly as a normal validation step.
Why
TypeScript checks handler types only during development. At runtime, handlers can return invalid data because of:
- malformed or semantically invalid LLM output;
- unexpected external API responses;
- unchecked casts or JavaScript callers;
- handler regressions;
- data-dependent branches that violate the declared return type.
pgflow persists handler results in step_states.output, uses them as dependent-step inputs, evaluates conditional patterns against them, and aggregates map results. Invalid output should be caught before it enters those paths.
Standard Schema support keeps pgflow independent of one validation library while preserving each library's behavior. Zod schemas still run through Zod itself, including refinements, transforms, defaults, and async validation. The same API can also accept Valibot, ArkType, and other Standard Schema-compatible libraries.
Proposed API
Regular step
import { z } from 'zod'
import { Flow } from '@pgflow/dsl'
const classificationSchema = z.strictObject({
route: z.enum(['respond', 'ignore']),
reason: z.string().min(1).max(280),
})
const flow = new Flow<{ text: string }>({ slug: 'classifyTicket' })
.step(
{
slug: 'classify',
outputSchema: classificationSchema,
maxAttempts: 3,
},
async ({ text }) => classifyText(text),
)
.step(
{
slug: 'notify',
dependsOn: ['classify'],
if: { classify: { route: 'respond' } },
},
async ({ classify }) => notifyTeam(classify),
)
The worker validates classifyText()'s return value before calling complete_task(). notify receives the schema's inferred output type, and its condition runs against validated data.
Array step
const articlesSchema = z.array(z.strictObject({
id: z.string().uuid(),
url: z.string().url(),
}))
const flow = new Flow<{ feedUrl: string }>({ slug: 'fetchArticles' })
.array(
{
slug: 'articles',
outputSchema: articlesSchema,
},
async ({ feedUrl }) => fetchArticles(feedUrl),
)
outputSchema validates the complete array returned by the handler. It may enforce array length, uniqueness refinements, or relationships between items.
Map step
const processedArticleSchema = z.strictObject({
id: z.string().uuid(),
summary: z.string().min(1),
})
const flow = new Flow<{ ids: string[] }>({ slug: 'processArticles' })
.array({ slug: 'ids' }, ({ ids }) => ids)
.map(
{
slug: 'processArticle',
array: 'ids',
itemOutputSchema: processedArticleSchema,
maxAttempts: 3,
},
async (id) => processArticle(id),
)
itemOutputSchema applies to each map task result, not to the aggregated array. A violation fails or retries only that item's task. Successful parsed items are aggregated in task_index order as today. The step output type is InferOutput<typeof processedArticleSchema>[].
The explicit itemOutputSchema name avoids implying that the schema receives the map step's aggregated array. .map() should reject outputSchema; .step() and .array() should reject itemOutputSchema.
Transforms and defaults
const normalizedUserSchema = z.strictObject({
email: z.string().email().transform((value) => value.toLowerCase()),
tags: z.array(z.string()).default([]),
})
const flow = new Flow<{ userId: string }>({ slug: 'normalizeUser' })
.step(
{
slug: 'user',
outputSchema: normalizedUserSchema,
},
async ({ userId }) => loadUser(userId),
)
The parsed value—not the raw handler return—is passed to complete_task(). Downstream steps and SQL queries see normalized/defaulted output.
Non-Zod schema
import * as v from 'valibot'
const resultSchema = v.object({
ok: v.boolean(),
value: v.string(),
})
const flow = new Flow<{ id: string }>({ slug: 'portableSchema' })
.step(
{
slug: 'load',
outputSchema: resultSchema,
},
async ({ id }) => loadResult(id),
)
No library adapter should be required. pgflow calls schema['~standard'].validate(value) and awaits either its synchronous or asynchronous result.
Type behavior
For a schema S extends StandardSchemaV1:
- handler return type should be assignable to
StandardSchemaV1.InferInput<S>;
- the step's public output type should be
StandardSchemaV1.InferOutput<S>;
- map step output should be
StandardSchemaV1.InferOutput<S>[];
- current JSON-compatibility constraints should continue to apply to the value persisted after validation;
- without a schema, existing handler-return inference and behavior stay unchanged.
Using separate schema input/output types preserves transforms. For example, a schema can accept an unnormalized string and expose a normalized string to downstream steps.
Violation behavior
Default: retry
.step(
{
slug: 'extract',
outputSchema: extractedDataSchema,
maxAttempts: 3,
// onOutputInvalid: 'retry' is the default
},
extractWithModel,
)
A schema violation becomes a normal task failure:
- the handler returns;
- validation fails;
- the worker reports failure through the existing
fail_task() path;
- normal
maxAttempts, retry delay, and whenExhausted behavior applies.
This default supports nondeterministic handlers such as LLM calls, where another attempt may return valid output.
Immediate hard failure
.step(
{
slug: 'normalize',
outputSchema: normalizedSchema,
onOutputInvalid: 'fail',
},
normalize,
)
'fail' means non-retryable failure: fail the step and run immediately, without applying whenExhausted. This matches the existing treatment of structural TYPE_VIOLATION failures.
Implementing this cleanly depends on a first-class permanent/non-retryable failure path rather than manually archiving PGMQ messages. This overlaps with the permanent/final failure use cases discussed in Discussion #240. If that mechanism is not ready, this feature can land in phases: support 'retry' first, then add 'fail' through the shared permanent-failure path.
Domain outcomes should remain normal return values, not permanent errors. For example, { status: 'resource_deleted' } is a valid, queryable completion; a schema contract violation is a workflow failure.
Error details and redaction
Introduce a typed worker error such as StepOutputValidationError.
Diagnostics should include only portable Standard Schema fields:
{
stepSlug: 'extract',
issues: [
{ path: ['items', 0, 'title'], message: 'Expected string' },
],
}
Do not include the invalid output or provider-specific received values in logs or persisted error messages. Outputs may contain secrets, personal data, or large payloads. Preserve the original issue objects on the in-memory error/cause if useful, but persist/log a redacted common representation.
Retry-safety caveat
Output validation happens after the handler returns. Any side effects performed by the handler may already have happened before a violation is found. If the task retries, those side effects may execute again.
Schemas do not make handlers exactly-once. Handlers must remain idempotent or retry-safe, especially for external side effects. Documentation should state this next to the default retry behavior.
Flow input validation: intentionally explicit for now
Do not add a hidden/ghost validation root step in the initial implementation.
A synthetic root step would:
- silently change the compiled flow shape;
- add one task and queue round-trip to every run;
- appear as unexpected monitoring noise;
- require rewiring all original root steps;
- change root handler signatures from direct flow input to dependency objects, or discard the parsed value and lose transforms/defaults.
Users can express input validation explicitly today:
const inputSchema = z.strictObject({
email: z.string().email(),
})
const flow = new Flow<z.input<typeof inputSchema>>({ slug: 'sendEmail' })
.step(
{ slug: 'validInput', maxAttempts: 1 },
(input) => inputSchema.parse(input),
)
.step(
{ slug: 'send', dependsOn: ['validInput'] },
({ validInput }) => sendEmail(validInput.email),
)
A future inputSchema option can validate at task claim time without changing flow shape, after the permanent-failure behavior is available. It should be a separate proposal.
Persistence and versioning
Schemas remain user-owned runtime objects:
- no JSON Schema conversion;
- no schema/hash columns;
- no compiled migration changes;
- no
FlowShape or startup shape-comparison changes;
- no SQL Core validation;
- no automatic version enforcement when a schema changes.
Changing a schema for an existing flow slug is therefore an undetected behavior change. Documentation should tell users to version the flow slug when they make an incompatible input/output contract change.
Implementation plan
1. Standard Schema dependency and helper
Add @standard-schema/spec and implement one internal validation helper:
async function validateOutput<S extends StandardSchemaV1>(
schema: S,
value: unknown,
): Promise<StandardSchemaV1.InferOutput<S>> {
const result = await schema['~standard'].validate(value)
if (result.issues) throw new StepOutputValidationError(result.issues)
return result.value
}
Standard Schema support should not convert or wrap the schema before validation. Calling the vendor's own validate preserves Zod refinements/transforms/defaults and equivalent features in other libraries.
2. DSL types (pkgs/dsl/src/dsl.ts)
- Extend
StepDefinition with a runtime-only internal task-result schema (for example taskOutputSchema?: StandardSchemaV1).
- Expose it as
outputSchema on .step() and .array().
- Expose it as
itemOutputSchema on .map() and normalize both public names to the same internal field.
- Add
onOutputInvalid?: 'retry' | 'fail' alongside that runtime-only schema metadata.
- Do not put schema objects in
StepRuntimeOptions; they are not SQL/runtime database options.
- Keep schemas out of
extractFlowShape() and compilation.
- Preserve schemas when fluent calls return a new immutable
Flow instance.
- Infer handler input and step output from
InferInput/InferOutput without changing existing no-schema overload behavior.
dsl.ts currently has multiple overloads for root/dependent/conditional/skippable variants. Avoid doubling every overload if possible; introduce reusable schema option/inference helpers and cover inference with type tests before choosing the final overload shape.
.array() currently delegates to .step(), so make sure schema metadata survives delegation. .map() builds its own StepDefinition, so normalize itemOutputSchema there.
3. Worker execution (pkgs/edge-worker/src/flow/StepTaskExecutor.ts)
After handler execution and before success logging/completeTask():
- find the task-result schema on the step definition;
- await Standard Schema validation;
- pass the parsed value to
completeTask();
- on violation, route the typed error through normal failure handling or the permanent-failure path based on
onOutputInvalid.
Validation time should count as task execution time. taskCompleted should log only after validation succeeds.
No extra database round-trip is needed. Validation runs once per task attempt inside the worker process.
4. Permanent-failure integration
Coordinate onOutputInvalid: 'fail' with the shared permanent-failure design related to Discussion #240.
The clean implementation likely needs the worker/client/Core failure API to distinguish retryable from non-retryable failures. Avoid schema-specific queue manipulation or direct pgmq.archive() calls.
5. Documentation
Update:
concepts/understanding-flows — schemas and parsed outputs;
concepts/map-steps — itemOutputSchema and per-task failure isolation;
build/retrying-steps — output violations and idempotency caveat;
build/validation-steps — explicit input validation remains the pattern;
tutorials/use-cases/structured-output — show schema validation at the pgflow boundary.
Test plan
DSL type tests
.step({ outputSchema }) exposes InferOutput<S> to dependents.
- Handler return accepts
InferInput<S> for transformed schemas.
.array({ outputSchema }) requires a schema whose output is an array.
.map({ itemOutputSchema }) produces InferOutput<S>[].
.map() rejects outputSchema.
.step() and .array() reject itemOutputSchema.
- No-schema flows preserve all existing inference.
- Conditional/skippable step typing still works with schema-derived outputs.
- Zod and at least one other Standard Schema library pass the same inference tests.
DSL runtime tests
- Schema metadata is retained in
StepDefinition across fluent Flow copies.
- Schema metadata is absent from extracted/compiled flow shape.
.array() delegation retains outputSchema.
.map() normalizes itemOutputSchema to the internal task-result schema.
Edge Worker tests
- Valid output calls
completeTask() with the parsed value.
- Zod transforms/defaults appear in the stored output.
- Invalid output does not call
completeTask().
- Default invalid output calls normal
failTask() and follows retries.
'fail' uses the permanent-failure path once available.
- Map validation applies independently to each task result.
- Async Standard Schema validation is awaited.
- Handler exceptions are not reclassified as schema failures.
- Diagnostics omit the invalid output and normalize nested paths safely.
Compatibility tests
- Zod refinements, transforms, defaults, and async validation retain their normal behavior.
- Valibot (or another Standard Schema implementation) works without an adapter.
- Existing flows without schemas require no code or migration changes.
Acceptance criteria
Summary
Add optional runtime output validation to flow handlers using the Standard Schema interface.
Proposed DSL:
.step({ outputSchema })validates the handler's complete output..array({ outputSchema })validates the complete returned array..map({ itemOutputSchema })validates each map task's returned item before aggregation.onOutputInvalid: 'retry' | 'fail'controls whether a schema violation uses normal retry handling or immediately hard-fails the step/run. Default:'retry'.Schemas stay in the runtime flow definition. They are not converted to JSON Schema, compiled into migrations, persisted in Postgres, or included in flow-shape comparison.
Flow input validation is intentionally out of scope for the initial implementation. Users can model it explicitly as a normal validation step.
Why
TypeScript checks handler types only during development. At runtime, handlers can return invalid data because of:
pgflow persists handler results in
step_states.output, uses them as dependent-step inputs, evaluates conditional patterns against them, and aggregates map results. Invalid output should be caught before it enters those paths.Standard Schema support keeps pgflow independent of one validation library while preserving each library's behavior. Zod schemas still run through Zod itself, including refinements, transforms, defaults, and async validation. The same API can also accept Valibot, ArkType, and other Standard Schema-compatible libraries.
Proposed API
Regular step
The worker validates
classifyText()'s return value before callingcomplete_task().notifyreceives the schema's inferred output type, and its condition runs against validated data.Array step
outputSchemavalidates the complete array returned by the handler. It may enforce array length, uniqueness refinements, or relationships between items.Map step
itemOutputSchemaapplies to each map task result, not to the aggregated array. A violation fails or retries only that item's task. Successful parsed items are aggregated intask_indexorder as today. The step output type isInferOutput<typeof processedArticleSchema>[].The explicit
itemOutputSchemaname avoids implying that the schema receives the map step's aggregated array..map()should rejectoutputSchema;.step()and.array()should rejectitemOutputSchema.Transforms and defaults
The parsed value—not the raw handler return—is passed to
complete_task(). Downstream steps and SQL queries see normalized/defaulted output.Non-Zod schema
No library adapter should be required. pgflow calls
schema['~standard'].validate(value)and awaits either its synchronous or asynchronous result.Type behavior
For a schema
S extends StandardSchemaV1:StandardSchemaV1.InferInput<S>;StandardSchemaV1.InferOutput<S>;StandardSchemaV1.InferOutput<S>[];Using separate schema input/output types preserves transforms. For example, a schema can accept an unnormalized string and expose a normalized string to downstream steps.
Violation behavior
Default: retry
A schema violation becomes a normal task failure:
fail_task()path;maxAttempts, retry delay, andwhenExhaustedbehavior applies.This default supports nondeterministic handlers such as LLM calls, where another attempt may return valid output.
Immediate hard failure
'fail'means non-retryable failure: fail the step and run immediately, without applyingwhenExhausted. This matches the existing treatment of structuralTYPE_VIOLATIONfailures.Implementing this cleanly depends on a first-class permanent/non-retryable failure path rather than manually archiving PGMQ messages. This overlaps with the permanent/final failure use cases discussed in Discussion #240. If that mechanism is not ready, this feature can land in phases: support
'retry'first, then add'fail'through the shared permanent-failure path.Domain outcomes should remain normal return values, not permanent errors. For example,
{ status: 'resource_deleted' }is a valid, queryable completion; a schema contract violation is a workflow failure.Error details and redaction
Introduce a typed worker error such as
StepOutputValidationError.Diagnostics should include only portable Standard Schema fields:
Do not include the invalid output or provider-specific received values in logs or persisted error messages. Outputs may contain secrets, personal data, or large payloads. Preserve the original issue objects on the in-memory error/cause if useful, but persist/log a redacted common representation.
Retry-safety caveat
Output validation happens after the handler returns. Any side effects performed by the handler may already have happened before a violation is found. If the task retries, those side effects may execute again.
Schemas do not make handlers exactly-once. Handlers must remain idempotent or retry-safe, especially for external side effects. Documentation should state this next to the default retry behavior.
Flow input validation: intentionally explicit for now
Do not add a hidden/ghost validation root step in the initial implementation.
A synthetic root step would:
Users can express input validation explicitly today:
A future
inputSchemaoption can validate at task claim time without changing flow shape, after the permanent-failure behavior is available. It should be a separate proposal.Persistence and versioning
Schemas remain user-owned runtime objects:
FlowShapeor startup shape-comparison changes;Changing a schema for an existing flow slug is therefore an undetected behavior change. Documentation should tell users to version the flow slug when they make an incompatible input/output contract change.
Implementation plan
1. Standard Schema dependency and helper
Add
@standard-schema/specand implement one internal validation helper:Standard Schema support should not convert or wrap the schema before validation. Calling the vendor's own
validatepreserves Zod refinements/transforms/defaults and equivalent features in other libraries.2. DSL types (
pkgs/dsl/src/dsl.ts)StepDefinitionwith a runtime-only internal task-result schema (for exampletaskOutputSchema?: StandardSchemaV1).outputSchemaon.step()and.array().itemOutputSchemaon.map()and normalize both public names to the same internal field.onOutputInvalid?: 'retry' | 'fail'alongside that runtime-only schema metadata.StepRuntimeOptions; they are not SQL/runtime database options.extractFlowShape()and compilation.Flowinstance.InferInput/InferOutputwithout changing existing no-schema overload behavior.dsl.tscurrently has multiple overloads for root/dependent/conditional/skippable variants. Avoid doubling every overload if possible; introduce reusable schema option/inference helpers and cover inference with type tests before choosing the final overload shape..array()currently delegates to.step(), so make sure schema metadata survives delegation..map()builds its ownStepDefinition, so normalizeitemOutputSchemathere.3. Worker execution (
pkgs/edge-worker/src/flow/StepTaskExecutor.ts)After handler execution and before success logging/
completeTask():completeTask();onOutputInvalid.Validation time should count as task execution time.
taskCompletedshould log only after validation succeeds.No extra database round-trip is needed. Validation runs once per task attempt inside the worker process.
4. Permanent-failure integration
Coordinate
onOutputInvalid: 'fail'with the shared permanent-failure design related to Discussion #240.The clean implementation likely needs the worker/client/Core failure API to distinguish retryable from non-retryable failures. Avoid schema-specific queue manipulation or direct
pgmq.archive()calls.5. Documentation
Update:
concepts/understanding-flows— schemas and parsed outputs;concepts/map-steps—itemOutputSchemaand per-task failure isolation;build/retrying-steps— output violations and idempotency caveat;build/validation-steps— explicit input validation remains the pattern;tutorials/use-cases/structured-output— show schema validation at the pgflow boundary.Test plan
DSL type tests
.step({ outputSchema })exposesInferOutput<S>to dependents.InferInput<S>for transformed schemas..array({ outputSchema })requires a schema whose output is an array..map({ itemOutputSchema })producesInferOutput<S>[]..map()rejectsoutputSchema..step()and.array()rejectitemOutputSchema.DSL runtime tests
StepDefinitionacross fluent Flow copies..array()delegation retainsoutputSchema..map()normalizesitemOutputSchemato the internal task-result schema.Edge Worker tests
completeTask()with the parsed value.completeTask().failTask()and follows retries.'fail'uses the permanent-failure path once available.Compatibility tests
Acceptance criteria
.step()and.array()accept optionaloutputSchema..map()accepts optionalitemOutputSchemawith per-item semantics.whenExhaustedbehavior.onOutputInvalid: 'fail'uses a first-class permanent-failure path, not direct queue manipulation.