Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
d5ba0f0
docs: spec uniform AsyncResult for activity calls (workstream 4, part 1)
btravers Aug 3, 2026
5f47bc9
docs: plan uniform AsyncResult for activity calls
btravers Aug 3, 2026
eead9de
test(worker): characterize activity-failure propagation before the Re…
btravers Aug 4, 2026
4f6b9bd
test(worker): discriminate WFT-timeout from real failure in propagati…
btravers Aug 4, 2026
3d4a2ee
feat(worker): add propagateActivityFailure for Temporal-faithful re-r…
btravers Aug 4, 2026
47eb351
fix(worker): re-raise declared ContractError's cause, cover wrapper r…
btravers Aug 4, 2026
7495b8a
docs(worker): correct ContractError doc claims in propagateActivityFa…
btravers Aug 4, 2026
05d7f06
feat(worker)!: return AsyncResult from every activity call
btravers Aug 4, 2026
105bb32
test(worker): migrate activity call sites to the uniform Result conve…
btravers Aug 4, 2026
fcb9012
docs(examples): adopt the uniform activity Result convention
btravers Aug 4, 2026
14de1d0
fix(examples): stop mislabeling a failed inventory reservation as out…
btravers Aug 4, 2026
1d54842
docs: migrate activity call sites and document the uniform Result con…
btravers Aug 4, 2026
3c45f0f
docs(worker): fix non-compiling cancellableScope examples and worker-…
btravers Aug 4, 2026
7fbd23a
docs(worker): capture scope Results and close remaining stale activit…
btravers Aug 4, 2026
3e87a36
docs(worker): fix rethrowCancellation JSDoc and unify cleanup guidance
btravers Aug 4, 2026
7cbc950
fix(worker): propagate child-workflow and scope cancellation failures
btravers Aug 4, 2026
1039046
docs: fix stale throwing-activity guidance and document the discarded…
btravers Aug 4, 2026
acc03f4
fix(worker): convert causeless ChildWorkflowError to ContractMisuseError
btravers Aug 4, 2026
fab2cb7
test(worker): widen no-declared-errors casts to ActivityCancelledError
btravers Aug 4, 2026
6409250
chore(contract): drop dead tag re-export in errors-impl.ts
btravers Aug 4, 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
52 changes: 44 additions & 8 deletions .agents/rules/handlers.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,13 @@ export const processOrder = declareWorkflow({
// context.cancellableScope / context.nonCancellableScope — see below

const inventory = await context.activities.validateInventory({ orderId: args.orderId });
return { status: inventory.available ? "confirmed" : "rejected" };
if (inventory.isDefect()) {
throw inventory.cause;
}
if (inventory.isErr()) {
return { status: "rejected" };
}
return { status: inventory.value.available ? "confirmed" : "rejected" };
},
});
```
Expand All @@ -92,13 +98,24 @@ Workflow code is deterministic — see [workflow-determinism.md](./workflow-dete

Typed-error semantics inside the workflow context:

- Activities **without** a declared `errors` map keep the throwing
`Promise<Output>` shape above.
- **Every** activity call returns `AsyncResult` — declared `errors` map or
not. There is no throwing `Promise<Output>` shape anymore; narrow the
result with `isOk()`/`isErr()`, or use `propagateActivityFailure` to let
the failure escape and have Temporal decide the workflow's outcome. A bare
`await context.activities.x(...)` compiles either way — it's easy to
discard the `AsyncResult` by accident and silently swallow a failure.
**Never** use unthrown's `.getOrThrow()` here: it throws the
`ActivityError`/`ActivityCancelledError` wrapper, which is a `TaggedError`
and not a `TemporalFailure` — Temporal retries that as a workflow-_task_
failure indefinitely instead of failing the workflow.
- Activities **with** a declared `errors` map return
`AsyncResult<Output, ContractError union | ActivityError | ActivityCancelledError>`
(mirroring the child-workflow API): declared failures rehydrate into typed
`ContractError`s, anything else is `Err(ActivityError)` with the unwrapped
cause, cancellation is `Err(ActivityCancelledError)`.
cause, cancellation is `Err(ActivityCancelledError)`. Activities
**without** a declared `errors` map return
`AsyncResult<Output, ActivityError | ActivityCancelledError>` — same
shape, minus the declared-error members.
- `context.errors` holds typed constructors for the workflow's own declared
errors; `throw context.errors.X(data)` fails the execution as an
`ApplicationFailure` the typed client rehydrates. Never throw a bare
Expand Down Expand Up @@ -149,16 +166,35 @@ Workflows opt into cancellation control via `context.cancellableScope` / `contex

```typescript
implementation: async (context, args) => {
// `fn`'s return value becomes the scope's `T` verbatim, so await and
// narrow the activity's own AsyncResult HERE, inside the callback —
// returning it un-awaited would make `T` the AsyncResult itself, which
// has no `isOk`/`isErr`/`.value`.
const result = await context.cancellableScope(async () => {
return context.activities.processStep(args);
const step = await context.activities.processStep(args);
if (step.isDefect()) {
throw step.cause;
}
return step.isOk();
});

if (result.isDefect()) {
throw result.cause; // a genuine bug thrown inside the scope, not a cancel
}
if (result.isErr()) {
// Workflow was cancelled. Cleanup that must not be cancelled itself
// goes inside `nonCancellableScope`.
await context.nonCancellableScope(async () => {
await context.activities.releaseResources(args);
// goes inside `nonCancellableScope`. Capture ITS OWN AsyncResult too —
// a bare `await` would silently discard both a defect thrown during
// cleanup and the un-awaited activity result.
const released = await context.nonCancellableScope(async () => {
const step = await context.activities.releaseResources(args);
if (step.isErr()) {
// best-effort cleanup — log and continue regardless
}
});
if (released.isDefect()) {
throw released.cause; // a genuine bug in cleanup, not a cancel
}
return { status: "cancelled" };
}

Expand Down
106 changes: 106 additions & 0 deletions .changeset/uniform-activity-result.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
---
"@temporal-contract/worker": major
---

Every workflow-side activity call now returns `AsyncResult`.

Previously the call convention depended on whether the contract declared an
`errors` map: activities with declared errors returned `AsyncResult`, those
without returned a `Promise` that threw. The call site gave no indication
which applied, and the throwing path contradicted the library's own
"activities never throw" convention.

**The dangerous part: a bare, discarded `await` compiles identically before
and after this change, and now silently swallows the failure.** Before,
`await context.activities.charge(input);` on its own line still threw on
failure (for an activity with no declared `errors` map) or, at worst, left an
unused `AsyncResult` you'd likely notice. Now every call returns
`AsyncResult`, so that exact line type-checks, discards the outcome, and lets
the workflow continue as if the call succeeded — nothing here is a type
error. Audit every activity call site: narrow the result or pass it through
`propagateActivityFailure` (below). Don't rely on the compiler to find these.

**Migrating.** Where the workflow should handle the failure, narrow it:

```ts
const result = await context.activities.charge(input);
if (result.isErr()) {
/* ... */
}
```

Where the failure should escape and let Temporal fail the workflow, use the
new `propagateActivityFailure` helper:

```ts
import { propagateActivityFailure } from "@temporal-contract/worker/workflow";

await propagateActivityFailure(context.activities.charge(input));
```

**Do not use unthrown's `.getOrThrow()` for this.** It throws the
`ActivityError` wrapper, which is not a `TemporalFailure`; Temporal treats
that as a workflow-_task_ failure and retries it indefinitely, so the
workflow stalls until its execution timeout instead of failing. The named
`propagateActivityFailure` helper rethrows the preserved original failure,
which is exactly what escaped the workflow before this change.

**Also note:** swallowing `ActivityCancelledError` makes a workflow complete
as `Completed` rather than `Cancelled`. That hazard previously applied only
to activities declaring an `errors` map; it now applies to every activity.

**If you wrap an activity call in `cancellableScope`/`nonCancellableScope`,
this is a source break, not just a behavior change.** Both scopes are generic
over whatever `fn` returns, verbatim — they do not await it for you. Before
this change, `() => context.activities.charge(input)` returned a plain
`Promise<Output>`, so the scope's own `T` was `Output`. Now it returns an
`AsyncResult<Output, E>`, and `AsyncResult` is deliberately not a full
`PromiseLike` (no `.catch`/`.finally`), so `T` becomes the un-awaited
`AsyncResult` itself — a type with no `isOk`/`isErr`/`.value`. Code like:

```ts
const scoped = await context.cancellableScope(() => context.activities.charge(input));
if (scoped.isOk()) {
scoped.value.transactionId; // ❌ no longer compiles — scoped.value is an AsyncResult
}
```

stops compiling. Await and narrow the activity call _inside_ the callback
instead:

```ts
const scoped = await context.cancellableScope(async () => {
const charged = await context.activities.charge(input);
if (charged.isDefect()) {
throw charged.cause;
}
if (charged.isErr()) {
return { ok: false as const, error: charged.error };
}
return { ok: true as const, value: charged.value }; // now a plain value, not an AsyncResult
});
```

`ActivityErrorsFor<TActivity>` — the error union used by
`WorkflowInferActivity`'s `AsyncResult` — is now exported from
`@temporal-contract/worker/workflow`, so consumers can name the error channel
directly (for example, to write a helper generic over an activity's error
type) instead of only the call signature.

`ActivityError` also grows a new `originalFailure` field: the failure exactly
as caught, before `cause`'s unwrap (typically Temporal's `ActivityFailure`
wrapper). It exists so `propagateActivityFailure` can re-raise the exact
failure Temporal originally produced without changing what `cause` means for
existing consumers — see [the reference docs](/reference/errors#activityerror).

`propagateActivityFailure` also accepts the `AsyncResult` returned by
`context.executeChildWorkflow` / `context.startChildWorkflow` and by
`context.cancellableScope` / `context.nonCancellableScope`. It re-raises
`ChildWorkflowCancelledError` / `WorkflowCancelledError` the same way it
re-raises a cancelled activity — their `cause` always holds the original
Temporal failure. `ChildWorkflowError` re-raises `cause` too when one is
present (a failed child execution), but three of its construction sites
(child input/output/signal-input validation) carry no `cause` at all; for
those, this helper converts to a terminal `ContractMisuseError` instead of
re-raising the bare `TaggedError`, so passing one of those through it does
not stall the workflow the way a bare `throw` of that `TaggedError` would.
2 changes: 1 addition & 1 deletion .changeset/v8-audit-remediation.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ v8 audit remediation — a second full-surface pass hardening robustness, the un
- The in-workflow handler binders are renamed to the `handle*` convention: `context.defineSignal`/`defineQuery`/`defineUpdate` → `context.handleSignal`/`handleQuery`/`handleUpdate` (no collision with the contract-authoring `define*` helpers or Temporal's own functions).
- Previously-internal types are now exported so implementations can be factored out of the `declareWorkflow`/`declareActivitiesHandler` calls: `WorkflowContext`, `DeclareWorkflowOptions`, `WorkflowImplementation`, the child-workflow handle types, the signal/query/update handler-implementation types, `WorkflowInferActivity`, `DeclareActivitiesHandlerOptions`, `TypedContinueAsNewOptions`, plus the new `ActivityImplementationFor` / `GlobalActivityImplementationFor` helpers.
- `qualifyFailure(errorType, options)` requires an `expected` discriminator (an error class, an array of classes, a predicate, or the explicit literal `"any"`). Causes matching `expected` are wrapped into the modeled `ApplicationFailure`; everything else — a `TypeError` from a bug, say — rides the **defect** channel instead of being mislabelled a business error. A matched inner `ApplicationFailure` with `nonRetryable: true` is inherited by default.
- New `rethrowCancellation(error)` helper. When an activity declares an `errors` map, cancellation surfaces as `Err(ActivityCancelledError)`; generic error handling that folds every `Err` to a fallback would complete the workflow instead of cancelling it. The cancellation error classes' JSDoc documents the hazard and the helper.
- New `rethrowCancellation(error)` helper. Cancelling an activity call surfaces as `Err(ActivityCancelledError)`; generic error handling that folds every `Err` to a fallback would complete the workflow instead of cancelling it. The cancellation error classes' JSDoc documents the hazard and the helper. (See the separate uniform-activity-result changeset in this same release: this hazard now applies to every activity call, not only ones declaring an `errors` map.)
- Async query/update schemas are rejected at bind time (`ContractMisuseError`) rather than on the first live request.
- `context.continueAsNew` can no longer have its validated `workflowType`/`taskQueue` overridden through the options bag.
- `ChildWorkflowError` carries a structured `workflowName`; the input/output `ValidationError` subclasses carry a `readonly direction: "input" | "output"`.
Expand Down
6 changes: 4 additions & 2 deletions docs/explanation/nexus.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ covers and drop to the SDK at the Nexus boundary:

```typescript
import * as nexus from "@temporalio/nexus";
import { declareWorkflow } from "@temporal-contract/worker/workflow";
import { declareWorkflow, propagateActivityFailure } from "@temporal-contract/worker/workflow";

const paymentService = nexus.service("PaymentService", {
charge: nexus.operation<{ customerId: string; amount: number }, { transactionId: string }>(),
Expand All @@ -81,7 +81,9 @@ export const processOrder = declareWorkflow({
activityOptions: { startToCloseTimeout: "1 minute" },
implementation: async (context, order) => {
// Contract-typed for everything local...
const reserved = await context.activities.reserveInventory({ items: order.items });
const reserved = await propagateActivityFailure(
context.activities.reserveInventory({ items: order.items }),
);

// ...raw SDK across the namespace boundary.
const client = nexus.createNexusClient({
Expand Down
94 changes: 60 additions & 34 deletions docs/explanation/the-result-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,50 +64,76 @@ const client = await TypedClient.create({ client: temporalClient }).get();

This is the table to internalize:

| Boundary | Shape | Why |
| --------------------------------------------------- | ------------------------------------------------------------------------------------ | --------------------------------------------- |
| **Activity implementation** returns | `AsyncResult<Output, ApplicationFailure \| ContractError>` | You author the failure. Explicit is better |
| **Workflow calls an activity** (no declared errors) | `Promise<Output>` — throws on failure | Temporal's retry policy is the handler |
| **Workflow calls an activity** (declared errors) | `AsyncResult<Output, ContractErrorUnion \| ActivityError \| ActivityCancelledError>` | You declared these to branch on them |
| **Workflow calls a child workflow** | `AsyncResult<Output, ChildWorkflow*Error>` | A peer operation; failure is usually a branch |
| **Workflow cancellation scope** | `AsyncResult<T, WorkflowCancelledError>` | Cancellation is an expected outcome |
| **Client calls a workflow** | `AsyncResult<Output, …>` | Crossing a process boundary |
| Boundary | Shape | Why |
| ----------------------------------- | -------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| **Activity implementation** returns | `AsyncResult<Output, ApplicationFailure \| ContractError>` | You author the failure. Explicit is better |
| **Workflow calls an activity** | `AsyncResult<Output, ActivityError \| ActivityCancelledError>` — plus `ContractErrorUnion` when declared | Uniform: every call returns a Result, whether or not the contract declares `errors` |
| **Workflow calls a child workflow** | `AsyncResult<Output, ChildWorkflow*Error>` | A peer operation; failure is usually a branch |
| **Workflow cancellation scope** | `AsyncResult<T, WorkflowCancelledError>` | Cancellation is an expected outcome |
| **Client calls a workflow** | `AsyncResult<Output, …>` | Crossing a process boundary |

### Every activity call returns a Result

Inside a workflow, `await context.activities.chargeCard(...)` gives you an
`AsyncResult` — never a plain value, and never a call that throws through.
That is true whether or not the contract declares an `errors` map: an activity
with no declared errors still folds into `Err(ActivityError |
ActivityCancelledError)` on failure, on the same channel as one with a full
declared error union. The call convention no longer depends on reading the
contract to find out whether a given activity call throws or returns a
`Result` — it's uniform.

### Why activities unwrap by default

Inside a workflow, `await context.activities.chargeCard(...)` gives you a plain
value. The `Result` is unwrapped for you.
```typescript
const charge = await context.activities.chargeCard({ customerId, amount });
if (charge.isErr()) {
// charge.error: ActivityError | ActivityCancelledError
}
```

That is not an inconsistency — it reflects who handles the failure. When an
activity fails, Temporal's retry policy takes over: it retries with backoff, and
only after exhausting the policy does the failure reach your workflow. By then
there is usually nothing sensible to do but let it propagate.
### Handle it, or let Temporal handle it

Forcing every activity call into a result fold would add ceremony to code whose
correct behaviour is almost always "let it throw":
Most activity failures still have one sensible response: let Temporal's retry
policy exhaust, then fail the workflow. Narrowing every such call site would
add ceremony to code whose correct behaviour is "let it throw" — so use
`propagateActivityFailure` to re-raise the original failure and hand the
outcome to Temporal, the same "let it throw" behaviour a bare `await` gave you
before this call convention became uniform:

```typescript
// what you write
const charge = await context.activities.chargeCard({ customerId, amount });
const shipment = await context.activities.createShipment({ orderId });
import { propagateActivityFailure } from "@temporal-contract/worker/workflow";

// what a uniform Result API would force
const charge = await context.activities.chargeCard({ customerId, amount });
if (charge.isErr()) return { status: "failed" };
const shipment = await context.activities.createShipment({ orderId });
if (shipment.isErr()) return { status: "failed" };
const charge = await propagateActivityFailure(
context.activities.chargeCard({ customerId, amount }),
);
const shipment = await propagateActivityFailure(context.activities.createShipment({ orderId }));
```

### Why declaring errors changes that
**Do not use unthrown's `.getOrThrow()` for this.** It throws the
`ActivityError`/`ActivityCancelledError` wrapper — a `TaggedError`, not a
`TemporalFailure` — and Temporal treats a non-`TemporalFailure` thrown from
workflow code as a workflow-_task_ failure, retrying it indefinitely rather
than failing the execution. `propagateActivityFailure` re-raises the
_preserved original_ Temporal failure instead, which is what actually fails
the workflow.

### Why declaring errors still matters

Declaring an `errors` map doesn't change the call _shape_ anymore — it changes
what's in the error _channel_. It folds the declared, rehydrated
`ContractError`s into the union alongside `ActivityError` /
`ActivityCancelledError`, and the exhaustive matcher then makes sure every
fold handles each one:

Declare an `errors` map on an activity and the call site becomes an
`AsyncResult`. That is the signal that you have failures the workflow is
_meant_ to branch on — and the exhaustive matcher then makes sure you handle
each one.
```typescript
const charged = await context.activities.chargeCard({ customerId, amount });
if (charged.isErr()) {
// charged.error: ContractErrorUnion<...> | ActivityError | ActivityCancelledError
}
```

It is an opt-in trade: ceremony in exchange for typed, exhaustive handling.
Declare errors on the activities whose failures drive workflow decisions; leave
the rest throwing.
Declare errors on the activities whose failures should drive workflow
decisions; for the rest, `propagateActivityFailure` keeps the call site to a
single line.

### Why child workflows never unwrap

Expand Down
Loading
Loading