Skip to content

Commit 887b278

Browse files
btraversclaude
andcommitted
docs!: v8 migration guide and full docs sweep for the review remediation
Documents the v8 review-remediation changes (D1-D3 plus the client split) across the hand-written docs, root README, and agent rules: - upgrade-to-v8: new migration sections for the TypedClient/ContractClient split (create({client}).for(contract), ContractClient annotations, no createOrThrow), the WorkflowNotInContractError rename, sync getHandle, deleted ClientInfer* aliases, the validate-on-send/parse-on-receive wire format (D1) and its behavioral notes, signal drop-and-log semantics (D2), qualify -> qualifyFailure, ContractMisuseError (D3), workflow-only workers, fail-fast declareActivitiesHandler, contract-package changes (strict root validation without zod, collision recalibration, input-less builders, InferContractWorkflows removal, ESM-only), and the typed schedule surface (ScheduleAlreadyExistsError/ScheduleNotFoundError, update/backfill/list) with an expanded checklist - validation-boundaries: rewritten around validate-on-send/parse-on-receive with the exactly-once transform rationale, signal-drop row, and the hand-rolled strict contract validation - reference: client-surface rewritten for the two-class surface (for(), raw, startUpdate, runId fields, sync getHandle, schedule additions); contract-surface (UndefinedInputSchema, input-less builders, strict root keys); worker-surface (ContractMisuseError, qualifyFailure, optional activities, child-handle signals, signal-drop policy); errors (renames, schedule errors, workflowId, channel tables) - how-to/tutorials: construction sweep to the two-step client form, input-less signal/query/update forms and omittable payloads, child signals map, workflow-only workers, schedule typed errors and create-if-absent idiom, sync getHandle call sites - agent rules: qualifyFailure rename, client-split guidance, hand-rolled contract validation wording Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 2b3be9b commit 887b278

32 files changed

Lines changed: 1079 additions & 276 deletions

.agents/rules/contract-patterns.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,9 @@ const processOrder = defineWorkflow({
5050
cancel: defineSignal({ input: z.object({ reason: z.string() }) }),
5151
},
5252
queries: {
53+
// `input` is optional on signals/queries/updates — omit it for an
54+
// argument-less definition (handler gets `undefined`).
5355
getStatus: defineQuery({
54-
input: z.object({}),
5556
output: z.object({ status: z.string() }),
5657
}),
5758
},
@@ -101,4 +102,4 @@ Any Standard Schema compatible library works:
101102
- `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
102103
- `defaultOptions` — contract-level `ActivityOptions` defaults (timeouts, retry). Merge precedence at the worker: `declareWorkflow` `activityOptions` < `defaultOptions` < `activityOptionsByName`
103104

104-
`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.
105+
`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`).

.agents/rules/handlers.md

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,10 @@ export const activities = declareActivitiesHandler({
2323
});
2424
```
2525

26-
`fromPromise(promise, qualify)` forces every rejection through `qualify`, which
26+
`fromPromise(promise, qualifyFailure)` forces every rejection through `qualifyFailure`, which
2727
returns the modeled error `E` (here an `ApplicationFailure`). For the common
28-
case, the worker package exports a `qualify(type, options?)` helper that builds
29-
that function — `fromPromise(inventoryService.check(orderId), qualify("INVENTORY_CHECK_FAILED"))`
28+
case, the worker package exports a `qualifyFailure(type, options?)` helper that builds
29+
that function — `fromPromise(inventoryService.check(orderId), qualifyFailure("INVENTORY_CHECK_FAILED"))`
3030
preserves an `Error` rejection's message and `cause`, with `options.nonRetryable`
3131
/ `options.details` / `options.message` (non-`Error` fallback) available. For a
3232
value you already have, use `OkAsync(value)` / `ErrAsync(failure)`, or lift an
@@ -103,10 +103,13 @@ Typed-error semantics inside the workflow context:
103103
`createWorker` returns `AsyncResult<Worker, never>` — bundling / connection
104104
failures are _technical_ infrastructure faults, so they ride the **defect**
105105
channel (a `TechnicalError` instance as the cause), not the modeled Err
106-
channel. Same shape on the client: `TypedClient.create({ contract, client })`
106+
channel. `activities` is optional — omit it for a workflow-only worker. Same
107+
shape on the client: the connection-scoped `TypedClient.create({ client })`
107108
returns `AsyncResult<TypedClient, never>` (a `TechnicalError`-caused defect on
108-
setup failure). Deprecated throwing aliases (`createWorkerOrThrow`,
109-
`TypedClient.createOrThrow`) exist for migration.
109+
setup failure); bind a contract with the synchronous, infallible
110+
`typedClient.for(contract)`, which returns a `ContractClient<TContract>` (the
111+
type to use in annotations). There is no `TypedClient.createOrThrow`; the
112+
deprecated `createWorkerOrThrow` still exists for migration.
110113

111114
```typescript
112115
import { createWorker, workflowsPathFromURL } from "@temporal-contract/worker/worker";
@@ -181,7 +184,7 @@ ApplicationFailure.create({
181184

182185
## Anti-patterns
183186

184-
- **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.
187+
- **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.
185188
- **Never use `any`** — use `unknown` and validate with schemas. Enforced by oxlint.
186189
- **Always use `.js` extensions** in imports (even for TypeScript files) — required by ESM module resolution.
187190
- **Don't `try/catch` `CancelledFailure` in workflows** — use `cancellableScope` so cancellation flows through the same `AsyncResult` discipline as everything else.

.agents/rules/project-overview.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,6 @@
3131

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

README.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -65,17 +65,19 @@ Implement the activities — note that workflow-scoped activities nest under the
6565
workflow, mirroring the contract:
6666

6767
```typescript
68-
import { declareActivitiesHandler, qualify } from "@temporal-contract/worker/activity";
68+
import { declareActivitiesHandler, qualifyFailure } from "@temporal-contract/worker/activity";
6969
import { fromPromise } from "unthrown";
7070

7171
export const activities = declareActivitiesHandler({
7272
contract: orderContract,
7373
activities: {
7474
processOrder: {
7575
chargeCard: ({ customerId, amount }) =>
76-
fromPromise(gateway.charge(customerId, amount), qualify("CHARGE_FAILED")).map((charge) => ({
77-
transactionId: charge.id,
78-
})),
76+
fromPromise(gateway.charge(customerId, amount), qualifyFailure("CHARGE_FAILED")).map(
77+
(charge) => ({
78+
transactionId: charge.id,
79+
}),
80+
),
7981
},
8082
},
8183
});
@@ -112,7 +114,8 @@ partial state, nothing to unwind.
112114
- **End-to-end type safety** — workflows, activities, signals, queries, updates,
113115
errors, and search attributes all derive from one contract
114116
- **Validation at every boundary** — Standard Schema (Zod, Valibot, ArkType) runs
115-
on both sides of every network hop
117+
on both sides of every network hop: validated on send, parsed on receive, so
118+
transforms apply exactly once
116119
- **Typed domain errors** — declare failures on the contract; consume them as
117120
schema-validated values with an exhaustive matcher
118121
- **Explicit error handling**`Result` / `AsyncResult` from

docs/examples/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ pnpm --filter @temporal-contract/sample-order-processing-worker test
6868
| ------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- |
6969
| [`contract.ts`](https://github.com/btravstack/temporal-contract/blob/main/examples/order-processing-contract/src/contract.ts) | The global vs workflow-scoped activity split |
7070
| [`workflows.ts`](https://github.com/btravstack/temporal-contract/blob/main/examples/order-processing-worker/src/application/workflows.ts) | Compensation logic, cancellation handling, per-activity options |
71-
| [`activities.ts`](https://github.com/btravstack/temporal-contract/blob/main/examples/order-processing-worker/src/application/activities.ts) | The nested implementation map, `fromPromise` + `qualify` |
71+
| [`activities.ts`](https://github.com/btravstack/temporal-contract/blob/main/examples/order-processing-worker/src/application/activities.ts) | The nested implementation map, `fromPromise` + `qualifyFailure` |
7272

7373
## Smaller, focused examples
7474

docs/explanation/nexus.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ contract covers its input and output again, at the cost of an extra hop:
119119
processOrder: {
120120
chargeViaNexus: ({ customerId, amount }) =>
121121
fromPromise(nexusClient.executeOperation("charge", { customerId, amount }),
122-
qualify("NEXUS_CHARGE_FAILED")),
122+
qualifyFailure("NEXUS_CHARGE_FAILED")),
123123
}
124124
```
125125

docs/explanation/the-result-model.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ three.
1717
| `defect` | A failure you did **not** model | `result.cause` |
1818

1919
An `err` is a value you produced on purpose — `Err(...)`, or a rejection mapped
20-
through `fromPromise(promise, qualify(...))`. It is part of your type signature,
20+
through `fromPromise(promise, qualifyFailure(...))`. It is part of your type signature,
2121
and callers are expected to branch on it.
2222

2323
A `defect` is what happens when something throws that you never modeled: a
@@ -57,7 +57,7 @@ anticipated failure modes_. Everything that can go wrong is a defect.
5757

5858
```typescript
5959
// `.get()` rethrows a defect's original cause — the right behaviour at startup
60-
const client = await TypedClient.create({ contract, client: temporalClient }).get();
60+
const client = await TypedClient.create({ client: temporalClient }).get();
6161
```
6262

6363
## The shapes at each boundary

docs/explanation/validation-boundaries.md

Lines changed: 65 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,75 @@
11
# Validation boundaries
22

3-
Where schemas run, why some data is validated twice, and what that buys.
3+
Where schemas run, why both sides of a hop run one, and what that buys.
44

55
## The map
66

7+
Every boundary follows one rule: **validate on send, parse on receive.** The
8+
sender runs the schema to fail early but transmits the caller's _original_
9+
value; the receiver parses, and the parsed value is what the handler sees.
10+
711
```
812
┌─ Client process ───────────────────────────────────────┐
913
│ executeWorkflow(args) │
1014
│ │ │
11-
│ ├─▶ ① workflow input schema
15+
│ ├─▶ ① validate workflow input (send original)
1216
│ ▼ │
1317
└──────┼──────────────────────────────────────────────────┘
1418
│ network
1519
┌──────▼─── Worker process ──────────────────────────────┐
1620
│ workflow function │
17-
│ ├─▶ ② workflow input schema (again)
21+
│ ├─▶ ② parse workflow input (handler gets it)
1822
│ │ │
1923
│ │ context.activities.chargeCard(input) │
20-
│ │ ├─▶ ③ activity input schema
24+
│ │ ├─▶ ③ validate activity input (send orig.)
2125
│ │ ▼ network │
2226
│ │ activity implementation │
23-
│ │ ├─▶ ④ activity input schema (again)
27+
│ │ ├─▶ ④ parse activity input
2428
│ │ │ ... your code ... │
25-
│ │ ├─▶ ⑤ activity output schema
29+
│ │ ├─▶ ⑤ validate activity output (send orig.)
2630
│ │ ▼ network │
27-
│ │ └─▶ ⑥ activity output schema (again)
31+
│ │ └─▶ ⑥ parse activity output
2832
│ │ │
29-
│ └─▶ ⑦ workflow output schema
33+
│ └─▶ ⑦ validate workflow output (send original)
3034
└──────┼──────────────────────────────────────────────────┘
3135
│ network
3236
┌──────▼─── Client process ──────────────────────────────┐
33-
│ └─▶ ⑧ workflow output schema (again)
37+
│ └─▶ ⑧ parse workflow output
3438
└─────────────────────────────────────────────────────────┘
3539
```
3640

37-
Signals, queries, and updates follow the same pattern: validated on the client
38-
before dispatch and on the worker before the handler runs.
41+
Signals, queries, updates, and child workflows follow the same pattern:
42+
validated on the sending side before dispatch, parsed on the receiving side
43+
before the handler (or caller) sees the value.
3944

40-
## Why validate twice
45+
## Why both sides run the schema
4146

42-
Points ①/② and ③/④ look redundant. They are not.
47+
Points ①/② and ③/④ look redundant. They are not — they do different jobs.
4348

44-
**The caller-side check is for diagnostics.** It catches bad data _before_ it
49+
**The send-side check is for diagnostics.** It catches bad data _before_ it
4550
crosses the network, so you get a descriptive schema error naming the offending
4651
field, at the call site, with a stack trace pointing at your code. Without it,
4752
the same mistake surfaces as a deserialization failure inside a worker you may
48-
not even own.
49-
50-
**The callee-side check is authoritative.** The worker cannot assume its caller
51-
used this library. A workflow may be started by the Temporal CLI, the Web UI,
52-
another SDK, or an older version of your own client. The contract is only a
53-
real guarantee if the side that enforces it is the side that runs the code.
54-
55-
The cost is a schema parse against data that already passed one — negligible
56-
next to a network round-trip.
53+
not even own. Its parsed result is deliberately **discarded** — the wire
54+
carries the original value.
55+
56+
**The receive-side parse is authoritative.** The worker cannot assume its
57+
caller used this library. A workflow may be started by the Temporal CLI, the
58+
Web UI, another SDK, or an older version of your own client. The contract is
59+
only a real guarantee if the side that enforces it is the side that runs the
60+
code.
61+
62+
**Parsing once is what keeps transforms correct.** Schemas can transform —
63+
`z.coerce.date()`, `.transform(...)`, `.default(...)`. If both sides applied
64+
the parse and the wire carried the parsed value, every transform would run
65+
twice, silently corrupting data (a date coerced twice, a default applied to an
66+
already-defaulted object). Because the sender transmits the original and only
67+
the receiver's parse "counts", **each transform applies exactly once per
68+
boundary**. It also means what travels the wire — and what you see in the
69+
Temporal Web UI or a raw history export — is the sender's original value.
70+
71+
The cost is one extra schema run per hop — negligible next to a network round
72+
trip.
5773

5874
## Fail fast, fail nowhere
5975

@@ -100,13 +116,22 @@ disagreeing.
100116
| Worker, entering a workflow | `WorkflowInputValidationError` | Thrown; terminal |
101117
| Worker, leaving a workflow | `WorkflowOutputValidationError` | Thrown; terminal |
102118
| Worker, entering/leaving an activity | `ActivityInputValidationError`, `ActivityOutputValidationError` | Thrown; terminal |
119+
| Worker, receiving a signal || Signal dropped; `log.warn` |
103120
| Contract error payload | `ContractErrorDataValidationError` | Thrown; terminal |
104121

105122
Worker-side validation errors extend Temporal's `ApplicationFailure` and are
106123
marked **non-retryable**. This is the right default: a schema mismatch is
107124
deterministic. Retrying the same payload against the same schema will fail
108125
identically, so retrying would only burn attempts and delay the real signal.
109126

127+
The signal row is the deliberate exception. A signal is a fire-and-forget
128+
message any stale client can send; failing the whole execution over one
129+
malformed payload would let any sender kill any workflow. The worker drops the
130+
signal and logs a warning (via `@temporalio/workflow`'s replay-aware
131+
`log.warn`, with the signal name and the schema issues) — the execution
132+
continues untouched. Client-side, a malformed signal still fails early with
133+
`SignalValidationError` before dispatch.
134+
110135
All of them carry `issues` — the raw Standard Schema issue array — for
111136
programmatic inspection, and a human-readable summary in `message`:
112137

@@ -121,18 +146,33 @@ if (result.isErr() && result.error instanceof WorkflowValidationError) {
121146
## Structure is validated too
122147

123148
`defineContract` validates the contract itself, at call time — not the data, the
124-
_shape_:
149+
_shape_ (a hand-rolled structural check; the contract package has no runtime
150+
schema-library dependency):
125151

126152
- `taskQueue` present and non-empty
127153
- at least one workflow
154+
- no unknown keys at the contract root (strict — only `taskQueue`,
155+
`workflows`, `activities`)
128156
- every name a valid JavaScript identifier
129157
- every schema slot Standard Schema compatible
130-
- no activity-name collisions in the flat runtime namespace
158+
- no activity-name collisions in the flat runtime namespace — reusing the
159+
_same_ definition object across workflows is fine (that is one activity, not
160+
a collision); two different definitions under one name is rejected, with a
161+
hint to hoist the shared activity to the global `activities` block
162+
- no workflow name colliding with a global activity name (they share the root
163+
of the worker's implementations map)
131164
- no unknown keys in `defaultOptions`
132165

133166
Because this runs at import time, a malformed contract fails when the process
134167
starts rather than when a workflow first executes.
135168

169+
Inside the workflow sandbox, contract misuse — binding a handler for an
170+
undeclared signal/query/update, an async-validating query schema, an activity
171+
no options cover — throws `ContractMisuseError`, a non-retryable
172+
`ApplicationFailure`. It fails the execution terminally instead of hanging it
173+
in an infinite Workflow Task retry loop, which is what a plain `Error` thrown
174+
from sandbox code would cause.
175+
136176
## Where middleware and interceptors sit
137177

138178
The two extension points sit on opposite sides of the boundary, and the

0 commit comments

Comments
 (0)