Workhorse
Producing work

Payload contracts

Version payload and result validators, bound durable JSON, and keep sensitive fields out of operator views.

A queue accepts whatever producers send it. Without a gate, a malformed payload becomes a durable job that fails in a handler hours later, and a payload carrying an access token becomes a row an operator can read on a dashboard. Payload contracts close both gaps: they reject the malformed value before anything is written, and they carry a redaction policy with every accepted job.

Define contracts where you create the queue

Pass contracts through the third Queue constructor argument, or through an adapter's queueOptions. Each job type names a currentVersion for newly accepted jobs and retains every version that live or redrivable jobs may still carry.

const contracts = {
  "mail.send": {
    currentVersion: "mail-current",
    versions: { "mail-current": {
      payloadSchema: { type: "object", required: ["recipient"] },
      resultSchema: { type: "object" },
      sensitivePayloadKeys: ["accessToken"],
    } },
  },
};
const queue = new Queue(pool, "default", { contracts });
await queue.syncContracts();

Call queue.syncContracts() during application startup. Python exposes sync_contracts, and Go exposes SyncContracts. PostgreSQL inserts an immutable document for every version and keeps the current version in a separate policy row, so an operator override survives the next deploy.

After synchronization, the TypeScript client caches the selected document for each job type. If an operator changes the selected version, PostgreSQL reports the stale selection so the client can refresh the document and validate the enqueue again.

Workhorse accepts the shared JSON Schema profile only. Bundled references work inside one document, formats stay annotations, and remote references or custom keywords are rejected before compilation.

Rejection is safe by construction

A schema mismatch produces a JobContractValidationError that names the job type, version, and whether the payload or result failed. Workhorse does not copy the rejected value or the library's diagnostic into that error, so a rejected payload cannot leak through the failure report.

The two directions fail differently:

  • Payload failures prevent enqueue. The queue validates before writing, so an invalid job never exists.
  • Result failures follow the retry path. The worker validates a handler's return value before completion; an invalid result is a failed attempt, not a committed success.

Size ceilings work the same way. defaultMaxPayloadBytes and defaultMaxResultBytes set queue-wide limits, and a contract version can override them with maxPayloadBytes and maxResultBytes. PostgreSQL measures its canonical JSON representation before the durable write, so clients cannot disagree about the accepted size — an oversized value throws JobValueSizeLimitError with the actual and allowed byte counts.

Hide sensitive fields from operators

sensitivePayloadKeys and sensitiveResultKeys name top-level object fields. The split is between execution and observation: when a worker claims a job, PostgreSQL returns the raw payload to its handler, because the handler needs the token to do its work. Job lookup, listing, dead letters, and dashboard detail remove the persisted sensitive keys, because an operator does not.

When a contract names sensitive fields, Workhorse also replaces handler error details before tracing or persistence — a thrown error that embeds the payload cannot smuggle it into telemetry.

Deploy a new version

Contracts are versioned because jobs outlive deployments. Each job stores the version selected when PostgreSQL accepted it. A worker loads that immutable document and caches it by job type and version, so a new deployment finishing an old job uses the old contract.

To change a shape: add the new version, move currentVersion to it, and keep old versions configured until no live or redrivable job can still carry them. A worker that claims a job whose version is no longer configured fails safely with JobContractUnavailableError instead of guessing.

Operator reads never run validators, so historical JSON stays readable even after the application stops accepting that shape for new jobs.

TypeScript applications provide the versioned registry through QueueOptions.contracts, so both enqueue validation and worker result validation use the same configured documents.

Next


Exact fields, limits, and failure behavior: architecture reference.