Skip to content

feat(tasks): validate task payloads and return values at runtime - #95

Merged
psteinroe merged 2 commits into
mainfrom
feat/runtime-validation
Oct 20, 2026
Merged

psteinroe merged 2 commits into
mainfrom
feat/runtime-validation

Conversation

@psteinroe

@psteinroe psteinroe commented Oct 20, 2026 •

Copy link
Copy Markdown
Owner

What

Workers now validate every execution of a task defined with defineTask() against its Standard Schemas (finding C25 / O5-06, owner decision D1).

  • Payload: validated before middleware and the handler run, for every invocation whatever its origin (conductor.invoke, ctx.invoke, signalWithStart, pgconductor.invoke() from SQL, dead-letter deliveries). The handler receives the schema's output, so transforms, defaults and stripped keys apply.
  • Return value: validated before completion. The schema's output is stored as the result.
  • Mismatch: the worker settles the execution as permanently_failed, so it is not retried. The error lists the issues, e.g. Invalid payload for task "charge": amount: Too small: expected number to be >0. Like any terminal failure, this fails waiting parents and goes to a configured dead-letter queue.
  • Validator that throws (as opposed to one that returns issues): the attempt fails and is retried, the same as a handler throwing.
  • Async validators are awaited.
  • Batches validate each member: members with an invalid payload fail without reaching the handler and the rest run as the batch. Return values are checked per item.
  • Type-only definitions (DefineTask + fromUnion) have no runtime schema and are not validated.

Why

Before this change, schemas only provided types. createTask never passed them to Task, so a payload that type-checked but broke a refinement (amount: -1 against z.number().positive()) reached the handler, and SQL invocations were never checked. The check has to happen in the worker because executions don't all go through the TypeScript invoke API.

How

  • createTask looks up the matching definition (name + queue) in TaskSchemas and hands its schemas to Task (Task.schemas).
  • lib/validate-schema.ts gains parseSchema, which returns either the issues or the parsed value and does not throw. validateSchema (metadata, events) now uses it.
  • In the worker, parseOrFail turns issues into permanently_failed and a thrown validator into failed. The single path validates inside the existing try. The batch path validates first and runs the batch with only the valid members (executeBatchTask → executeBatch).
  • conductor.signalWithStart / ctx.signalWithStart without input now start the execution with {} instead of null, like ctx.invoke without a payload. A null payload would fail any object schema; signals.test.ts (from fix(retries): make retried writes from running executions replay-safe #90) starts a session this way.
  • Invoking does not validate. Validating on invoke would store transformed payloads that the worker then parses a second time.

Return value decision

An invalid return value fails permanently, the same as an invalid payload. It's a contract violation by the handler's code, and a retry replays memoized steps, so it would almost always reproduce the same output.

Tests

tests/integration/task-validation.test.ts: refinements, SQL-originated invocations (wrong type, missing payload, valid), transforms and stripped keys, schema defaults on SQL payloads, an async Standard Schema validator, invalid return values, a throwing validator (retried), a batch with one invalid payload and one invalid return value, and type-only definitions. On main, 8 of the 9 tests fail; the type-only test passes.

just lint, just format, pnpm typecheck and the full pnpm test pass (538 tests).

Docs

  • defining-tasks.md: new "Runtime Validation" section.
  • batching.md: per-item validation.
  • dead-letter-queue.md: a destination with a payload schema validates the source payload too.
  • AGENTS.md: worker bullet.

Follow-ups (out of scope)

  • Custom-event payloads are validated only by conductor.emit / ctx.emit. Events emitted from SQL (emit_event()) reach triggered tasks unvalidated.
  • The async validation steps aren't covered by timeoutMs or cancellation: payload validation runs before the handler race and return validation after it.

@psteinroe
psteinroe force-pushed the feat/runtime-validation branch from 2daecdd to 993144b Compare October 20, 2026 13:19
@psteinroe
psteinroe merged commit 0a46827 into main Oct 20, 2026
11 checks passed
@psteinroe
psteinroe deleted the feat/runtime-validation branch October 20, 2026 14:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant