# 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.

<Tabs items={["TypeScript", "Python", "Go"]}>
  <Tab value="TypeScript">
    ```ts
    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();
    ```
  </Tab>
  <Tab value="Python">
    ```python
    contracts = {"mail.send": JobTypeContracts(
        current_version="mail-current",
        versions={"mail-current": JobContractVersion(
            payload_schema={"type": "object", "required": ["recipient"]},
            result_schema={"type": "object"},
            sensitive_payload_keys=["accessToken"],
        )},
    )}
    queue.sync_contracts(contracts)
    ```
  </Tab>
  <Tab value="Go">
    ```go
    contracts := map[string]workhorse.JobTypeContracts{"mail.send": {
        CurrentVersion: "mail-current",
        Versions: map[string]workhorse.JobContractVersion{"mail-current": {
            PayloadSchema: map[string]any{"type": "object", "required": []string{"recipient"}},
            ResultSchema: map[string]any{"type": "object"},
            SensitivePayloadKeys: []string{"accessToken"},
        }},
    }}
    err := queue.SyncContracts(ctx, contracts)
    ```
  </Tab>
</Tabs>

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

- [Enqueue and transactions](/docs/enqueue) — create jobs atomically
- [Schedules](/docs/schedules) — recurring definitions capture the current contract
- [Workers](/docs/workers) — how invalid results enter the failure path

---

Exact fields, limits, and failure behavior:
[architecture reference](https://github.com/stablemates/workhorse/blob/main/docs/architecture.md#job).
