Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
d4c1191
docs: add design spec for binding a second contract to a TypedClient
Jul 29, 2026
3b414ba
docs: revise client spec to decouple the client from the contract
Jul 29, 2026
0274e9b
docs: record why the worker stays contract-bound and tighten the clie…
btravers Jul 29, 2026
fd7f11a
Merge branch 'spec/typed-client-contract-binding' into feat/v8-full-r…
btravers Jul 29, 2026
e2f92f7
docs: record v8 review remediation decisions and work breakdown
btravers Jul 29, 2026
18c305b
docs: fix beta install path and stale guidance from v8 review
btravers Jul 29, 2026
7a92499
feat(contract)!: drop zod meta-validation, ESM-only build, input-less…
btravers Jul 29, 2026
a258fa8
fix!: parse payloads once per boundary (validate on send, transmit or…
btravers Jul 29, 2026
6679d20
feat(worker)!: signal drop policy, contract-misuse failures, and work…
btravers Jul 29, 2026
9c1b453
feat(client)!: split TypedClient/ContractClient and overhaul the clie…
btravers Jul 29, 2026
da6d96a
feat(examples)!: port to the v8 split API and demonstrate signals, qu…
btravers Jul 29, 2026
8281e80
docs!: v8 migration guide and full docs sweep for the review remediation
btravers Jul 29, 2026
17ec6e6
feat(testing)!: contract-aware fixtures and configurable environments
btravers Jul 30, 2026
c85a912
chore!: release prep — package checks, changesets, and reconciliation…
btravers Jul 30, 2026
895654d
feat(worker)!: remove deprecated createWorkerOrThrow
btravers Jul 30, 2026
4928308
chore!: family hygiene from the amqp-contract cross-review
btravers Jul 30, 2026
e8d1e01
fix(client): name the received runtime type in search-attribute errors
btravers Jul 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .agents/rules/contract-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,9 @@ const processOrder = defineWorkflow({
cancel: defineSignal({ input: z.object({ reason: z.string() }) }),
},
queries: {
// `input` is optional on signals/queries/updates — omit it for an
// argument-less definition (handler gets `undefined`).
getStatus: defineQuery({
input: z.object({}),
output: z.object({ status: z.string() }),
}),
},
Expand Down Expand Up @@ -101,4 +102,4 @@ Any Standard Schema compatible library works:
- `errors` — same shape as workflow errors; produced via the `errors` constructors in the implementation's helpers argument, and rehydrated as a typed `AsyncResult` error union on the workflow side
- `defaultOptions` — contract-level `ActivityOptions` defaults (timeouts, retry). Merge precedence at the worker: `declareWorkflow` `activityOptions` < `defaultOptions` < `activityOptionsByName`

`defineContract` rejects collisions between workflow-local and global activity names at runtime — `defineContract` runs a Zod validation pass and throws a descriptive error. Activities share a single flat namespace at the worker level, so two activities can't share a name even across workflows. See `packages/contract/src/builder.ts:441` for the validation schema.
`defineContract` validates the contract's structure at runtime with a hand-rolled structural validator (no zod runtime dependency) and throws a descriptive error: strict root keys (only `taskQueue`/`workflows`/`activities`), identifier-safe names, Standard Schema slots, and collision checks. Activities share a single flat namespace at the worker level, so two _different_ definitions can't share a name even across workflows — but reusing the **same definition object** across workflows is allowed (it's one activity), and the collision message recommends hoisting shared activities to the global `activities` block. A workflow name colliding with a global activity name is also rejected (they share the root of the worker implementations map). See `packages/contract/src/builder.ts` (`validateContractDefinition`).
35 changes: 19 additions & 16 deletions .agents/rules/dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,17 @@

## Key Dependencies

| Dependency | Where it's used |
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `@temporalio/client` | Temporal client SDK — peer dep of `client` |
| `@temporalio/worker` | Temporal worker SDK — peer dep of `worker` |
| `@temporalio/workflow` | Temporal workflow API — peer dep of `worker` |
| `@temporalio/common` | Shared Temporal types — peer dep of `client`/`worker` |
| `@standard-schema/spec` | Standard Schema specification — direct dep |
| `unthrown` | `Result` / `AsyncResult` — peer dep of `client`/`worker` |
| `zod` | Direct dep of `contract` (used internally for the `defineContract` runtime validation pass); user-side schema lib for the others |
| `valibot` / `arktype` | User-side schema libraries (Standard Schema) |
| Dependency | Where it's used |
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `@temporalio/client` | Temporal client SDK — peer dep of `client` |
| `@temporalio/worker` | Temporal worker SDK — peer dep of `worker` |
| `@temporalio/workflow` | Temporal workflow API — peer dep of `worker` |
| `@temporalio/common` | Shared Temporal types — peer dep of `client`/`worker` |
| `@temporalio/testing` | Time-skipping test server (`TestWorkflowEnvironment`) — peer dep of `testing` |
| `@standard-schema/spec` | Standard Schema specification — direct dep |
| `unthrown` | `Result` / `AsyncResult` — peer dep of `client`/`worker` |
| `zod` | User-side schema library (Standard Schema); dev-only in this repo — `defineContract`'s structural validation is hand-rolled |
| `valibot` / `arktype` | User-side schema libraries (Standard Schema) |

`pino` appears in the catalog and is used by `examples/` only — it's not imported from any published package's `src/`.

Expand All @@ -37,15 +38,17 @@ All dependency versions are centralized in `pnpm-workspace.yaml` under the `cata

Anything that appears in a published package's **public type signatures** must be a peer dep, not a regular dep — otherwise downstream consumers can end up with two disjoint nominal types in their typechecker (theirs and ours), even though the runtime classes are compatible.

| Package | Peer dependencies |
| -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| client | `@temporalio/client ^1`, `@temporalio/common ^1`, `unthrown ^5` |
| worker | `@temporalio/common ^1`, `@temporalio/worker ^1`, `@temporalio/workflow ^1`, `unthrown ^5` |
| contract | `unthrown ^5` (optional — only needed when using `result-async`) |
| testing | `vitest ^4` (the `globalSetup` hook integrates with vitest's test runner), `@temporalio/client ^1`, `@temporalio/worker ^1` (both exposed by the `it` fixture's public types) |
| Package | Peer dependencies |
| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| client | `@temporalio/client ^1`, `@temporalio/common ^1`, `unthrown ^5` |
| worker | `@temporalio/common ^1`, `@temporalio/worker ^1`, `@temporalio/workflow ^1`, `unthrown ^5` |
| contract | `unthrown ^5` (optional — only needed when using `result-async`) |
| testing | `@temporal-contract/client`, `@temporal-contract/contract`, `@temporal-contract/worker` (concrete `^8.x` ranges — see below), `unthrown ^5` (the contract-aware fixtures expose `TypedClient`/`ContractClient`/`ActivitiesHandler`/`AsyncResult` in their public types), `vitest ^4` (the `globalSetup` hook integrates with vitest's test runner), `@temporalio/client ^1`, `@temporalio/testing ^1`, `@temporalio/worker ^1` (all exposed by the fixtures' public types — e.g. `TestWorkflowEnvironment` in `time-skipping`) |

When you add a peer dep, also add it to `devDependencies` (with the same `"catalog:"` reference) so the local workspace build still resolves it. The workspace has `autoInstallPeers: false`, so peers must be present somewhere on the install side.

**Deliberate exception:** the testing package's `@temporal-contract/*` sibling peers have NO matching devDeps. client and worker devDepend on testing for their integration fixtures, so adding the siblings to testing's devDeps would put a cycle in the package graph, which turbo 2 rejects. Local resolution goes through tsconfig `paths` mapped to the siblings' sources plus vitest aliases instead — see the comments in `packages/testing/tsconfig.json` and `packages/testing/vitest.config.ts`. For the same reason these peers use **concrete `^8.x` semver ranges, not `workspace:^`**: pnpm can only rewrite the `workspace:` protocol at pack/publish time for deps that are actually installed, so `workspace:^` here breaks `pnpm pack`/`pnpm publish` (`ERR_PNPM_CANNOT_RESOLVE_WORKSPACE_PROTOCOL`). Changesets keeps the ranges bumped (`updateInternalDependencies: "patch"`, and the four packages are a fixed group).

## Security `overrides` (`pnpm-workspace.yaml`)

`pnpm-workspace.yaml` pins minimum versions for transitive dependencies via its `overrides:` block to close known CVEs (currently `fast-uri`, `protobufjs`, and `testcontainers>undici`). When a security audit flags a new vulnerability, add the pin there (with a comment citing the GHSA and the reachability reasoning) rather than waiting for upstream to update. Advisories that are unreachable in this repo are suppressed via `auditConfig.ignoreGhsas`, each with a documented justification.
Expand Down
28 changes: 15 additions & 13 deletions .agents/rules/handlers.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,15 @@ export const activities = declareActivitiesHandler({
});
```

`fromPromise(promise, qualify)` forces every rejection through `qualify`, which
`fromPromise(promise, qualifyFailure)` forces every rejection through `qualifyFailure`, which
returns the modeled error `E` (here an `ApplicationFailure`). For the common
case, the worker package exports a `qualify(type, options?)` helper that builds
that function — `fromPromise(inventoryService.check(orderId), qualify("INVENTORY_CHECK_FAILED"))`
case, the worker package exports a `qualifyFailure(type, options?)` helper that builds
that function — `fromPromise(inventoryService.check(orderId), qualifyFailure("INVENTORY_CHECK_FAILED"))`
preserves an `Error` rejection's message and `cause`, with `options.nonRetryable`
/ `options.details` / `options.message` (non-`Error` fallback) available. For a
value you already have, lift a sync result with `Ok(value).toAsync()` /
`Err(failure).toAsync()` — unthrown has no `okAsync`/`errAsync`.
value you already have, use `OkAsync(value)` / `ErrAsync(failure)`, or lift an
existing sync `Result` with `Ok(value).toAsync()` / `Err(failure).toAsync()` —
unthrown has no lowercase `okAsync`/`errAsync`.

Canonical example: `examples/order-processing-worker/src/application/activities.ts`.

Expand Down Expand Up @@ -102,10 +103,13 @@ Typed-error semantics inside the workflow context:
`createWorker` returns `AsyncResult<Worker, never>` — bundling / connection
failures are _technical_ infrastructure faults, so they ride the **defect**
channel (a `TechnicalError` instance as the cause), not the modeled Err
channel. Same shape on the client: `TypedClient.create({ contract, client })`
channel. `activities` is optional — omit it for a workflow-only worker. Same
shape on the client: the connection-scoped `TypedClient.create({ client })`
returns `AsyncResult<TypedClient, never>` (a `TechnicalError`-caused defect on
setup failure). Deprecated throwing aliases (`createWorkerOrThrow`,
`TypedClient.createOrThrow`) exist for migration.
setup failure); bind a contract with the synchronous, infallible
`typedClient.for(contract)`, which returns a `ContractClient<TContract>` (the
type to use in annotations). There is no `TypedClient.createOrThrow` and no
`createWorkerOrThrow` — use `.get()` on the returned `AsyncResult`.

```typescript
import { createWorker, workflowsPathFromURL } from "@temporal-contract/worker/worker";
Expand All @@ -129,14 +133,12 @@ await workerResult.value.run();
Workflows opt into cancellation control via `context.cancellableScope` / `context.nonCancellableScope`. They fold cancellation into the project's `AsyncResult` shape — callers branch on `Err(WorkflowCancelledError)` instead of catching `CancelledFailure`.

```typescript
import { isErr } from "unthrown";

implementation: async (context, args) => {
const result = await context.cancellableScope(async () => {
return context.activities.processStep(args);
});

if (isErr(result)) {
if (result.isErr()) {
// Workflow was cancelled. Cleanup that must not be cancelled itself
// goes inside `nonCancellableScope`.
await context.nonCancellableScope(async () => {
Expand All @@ -151,7 +153,7 @@ implementation: async (context, args) => {

- `cancellableScope<T>(fn)` — returns `AsyncResult<T, WorkflowCancelledError>`. Cancels propagate from outside.
- `nonCancellableScope<T>(fn)` — same shape; _outside_ cancels are ignored. Cancels raised _inside_ still surface as `Err(...)`. Use for graceful-shutdown cleanup.
- Non-cancellation errors thrown by `fn` are _unmodeled_ failures: they ride unthrown's **`defect`** channel (inspectable via `isDefect(result)` / `result.cause`, re-thrown at the edge), not the modeled `err` channel.
- Non-cancellation errors thrown by `fn` are _unmodeled_ failures: they ride unthrown's **`defect`** channel (inspectable via `result.isDefect()` / `result.cause`, re-thrown at the edge), not the modeled `err` channel.

Canonical implementation: `packages/worker/src/cancellation.ts:38` (`cancellableScope`), `:75` (`nonCancellableScope`). Error class: `packages/worker/src/errors.ts:193`.

Expand Down Expand Up @@ -182,7 +184,7 @@ ApplicationFailure.create({

## Anti-patterns

- **Never throw** from activities — Temporal sees thrown errors as `ApplicationFailure(type: "Error", retryable: true)` by default, which masks the real failure type and triggers unwanted retries. Use `Err(ApplicationFailure.create({ type, message, nonRetryable })).toAsync()` (or a `fromPromise(promise, qualify)` chain whose `qualify` returns the `ApplicationFailure`) instead.
- **Never throw** from activities — Temporal sees thrown errors as `ApplicationFailure(type: "Error", retryable: true)` by default, which masks the real failure type and triggers unwanted retries. Use `Err(ApplicationFailure.create({ type, message, nonRetryable })).toAsync()` (or a `fromPromise(promise, qualifyFailure)` chain whose `qualifyFailure` returns the `ApplicationFailure`) instead.
- **Never use `any`** — use `unknown` and validate with schemas. Enforced by oxlint.
- **Always use `.js` extensions** in imports (even for TypeScript files) — required by ESM module resolution.
- **Don't `try/catch` `CancelledFailure` in workflows** — use `cancellableScope` so cancellation flows through the same `AsyncResult` discipline as everything else.
2 changes: 1 addition & 1 deletion .agents/rules/project-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,6 @@

- **Contract** — defines task queue, workflows, activities, signals, queries, updates, search attributes with schemas. See [contract-patterns.md](./contract-patterns.md).
- **Worker** — `declareWorkflow` + `declareActivitiesHandler` with automatic validation. See [handlers.md](./handlers.md).
- **Client** — `TypedClient.create()` returns `AsyncResult<T, E>` for all operations.
- **Client** — `TypedClient.create({ client })` is connection-scoped; `client.for(contract)` hands out a contract-bound `ContractClient` whose operations return `AsyncResult<T, E>`.
- **Result** — `Result<T, E>` and `AsyncResult<T, E>` from unthrown for explicit error handling, plus a third `defect` channel for unanticipated failures.
- **Determinism** — workflow code runs in Temporal's replay sandbox. See [workflow-determinism.md](./workflow-determinism.md).
Loading
Loading