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
c6f4bea
docs(spec): design for determinism and money-safety invariants
btravers Aug 2, 2026
95e8036
docs(plan): implementation plan for the determinism invariants
btravers Aug 2, 2026
a8cb2a5
docs(rules): correct the determinism rule — the sandbox patches most …
btravers Aug 2, 2026
90a42d3
docs(rules): fix a leftover contradiction in the escape-hatch section
btravers Aug 2, 2026
28f20b4
test(testing): add the testRig replay-harvest helper
btravers Aug 2, 2026
3c6852b
fix(testing): close three replay-coverage gaps in testRig review
btravers Aug 2, 2026
951f964
test(worker): move replay.inprocess.spec.ts onto the rig
btravers Aug 2, 2026
24feaac
fix(worker): narrow the testRig alias to integration-inprocess only
btravers Aug 2, 2026
2634dbe
test(worker): move handlers and activity-options onto the rig
btravers Aug 2, 2026
eecc5a6
fix(worker): resolve the fourth peer-dep specifier tsc missed, narrow…
btravers Aug 2, 2026
387f996
test(worker): move cancellation and continue-as-new onto the rig
btravers Aug 2, 2026
cc23da5
test(worker): move the remaining in-process specs onto the rig
btravers Aug 2, 2026
871d358
test(worker): prove nonRetryable by attempt count, not by field
btravers Aug 2, 2026
c29d2ae
test(worker): prove timeout forwarding through every merge layer
btravers Aug 2, 2026
a826bab
test(testing): make replaySkipAllowlist a caller param, guard START_M…
btravers Aug 2, 2026
aaecb56
test(worker): move REPLAY_SKIP_ALLOWLIST fixture out of testing package
btravers Aug 2, 2026
764bac6
test(testing): require testRig( in every in-process spec test
btravers Aug 2, 2026
28e4f65
docs(worker): correct three misleading test-fixture comments
btravers Aug 2, 2026
915f1a8
chore: add changeset for the test-rig subpath export
btravers Aug 2, 2026
1502fcc
fix(testing): close four fail-open paths in the rig and its adoption …
btravers Aug 2, 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
45 changes: 31 additions & 14 deletions .agents/rules/workflow-determinism.md
Original file line number Diff line number Diff line change
@@ -1,22 +1,37 @@
# Workflow Determinism

Workflow code runs inside Temporal's deterministic replay sandbox. Every time a workflow is rehydrated (worker restart, sticky-task reassignment, history replay), Temporal re-runs the implementation from the start and **must produce the exact same sequence of commands**. Any non-determinism — wall-clock reads, native randomness, direct I/O — will desync from history and crash the workflow with a non-determinism error.
Workflow code runs inside Temporal's deterministic replay sandbox. Every time a workflow is rehydrated (worker restart, sticky-task reassignment, history replay), Temporal re-runs the implementation from the start and **must produce the exact same sequence of commands**. A desync from history crashes the workflow with a non-determinism error.

This is THE most error-prone area in any Temporal codebase. Read it.
**The sandbox does more than people assume.** `@temporalio/workflow/lib/global-overrides.js` rewrites the common hazards before your code runs, and hard-blocks a couple more. So the guidance below is mostly about **semantics, not safety** — the patched APIs work, they just don't mean what their names suggest. Knowing which column a thing is in tells you whether a mistake shows up as a crash, a surprise, or silent corruption.

## Banned in workflow code
### Patched — safe, but the semantics differ

| Don't | Use instead |
| ---------------------------------- | ----------------------------------------------------------------------------------------------------- |
| `Date.now()` / `new Date()` | `workflowInfo().startTime` or `Date` from `@temporalio/workflow` (it's monkey-patched in the sandbox) |
| `Math.random()` | `uuid4()` from `@temporalio/workflow`, or do RNG inside an activity |
| `crypto.randomUUID()` / `crypto.*` | `uuid4()` from `@temporalio/workflow`, or activity |
| `setTimeout` / `setInterval` | `sleep(duration)` from `@temporalio/workflow` |
| `process.env.*` | Pass via `args` or read inside an activity |
| `fetch` / `http` / database / disk | Wrap in an activity — workflows must not touch I/O |
| `import.meta.*` / `__dirname` | Constant inputs; or read inside an activity |
These are rewritten in the sandbox. Using them will not break replay. Prefer the explicit primitive anyway, because the behavior is not what the name implies.

The rule of thumb: **if it can return a different value on a second call with the same inputs, it doesn't belong in workflow code.** Push it into an activity, where retries and non-determinism are explicitly handled.
| API | What it actually does | Prefer |
| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- |
| `Date.now()` / `new Date()` | Returns **workflow time** (`getActivator().now`), not wall clock. On replay it returns the historical value. | `workflowInfo().startTime` when you mean "when did this start" |
| `Math.random()` | A **seeded deterministic stream**. Replay-safe, but the sequence shifts when you change consuming code. | `uuid4()`, or do RNG in an activity |
| `setTimeout` / `setInterval` | Becomes a **durable Temporal timer** wrapped in a cancellation scope. Survives worker restarts; advances with workflow time. | `sleep(duration)` — same thing, honest name |

### Blocked loudly — you cannot get this wrong silently

| API | What happens |
| ------------------------------------ | ------------------------------------------------------------------------------- |
| `WeakRef` / `FinalizationRegistry` | Throws `DeterminismViolationError` — "v8 GC is non-deterministic" |
| `crypto.*`, `fetch`, `fs`, net, disk | Not present in the sandbox context; reference errors rather than nondeterminism |

### Genuinely unprotected — this is where the real risk lives

Nothing patches these. They are the reason this rule exists.

| Hazard | Why it bites | Do instead |
| ----------------------------- | ------------------------------------------------------------------------------------------------------- | --------------------------------------- |
| `process.env.*` | Legitimately differs per worker and per deployment. Two workers replaying the same history can diverge. | Pass via `args`, or read in an activity |
| Module-level mutable state | Shared across executions in a reused VM context; ordering is not yours to control. | Keep workflow modules pure |
| `import.meta.*` / `__dirname` | Environment-dependent, and not something history records. | Constant inputs |

The rule of thumb still holds — **if it can return a different value on a second call with the same inputs, it doesn't belong in workflow code** — but note the sandbox already enforces most of it. Spend your vigilance on the third table.

## Why activities can do anything

Expand All @@ -30,7 +45,9 @@ Use `context.cancellableScope` / `context.nonCancellableScope` (`packages/worker

## Side-effect escape hatch

If you absolutely need non-determinism inside workflow code (e.g. logging at a checkpoint), use `LocalActivity` with `proxyLocalActivities` from `@temporalio/workflow` — same sandboxing rules but lower overhead than a full network round-trip. Even logging via `console.log` is fine in workflow code (`@temporalio/workflow` patches it through Temporal's logger), but `console.log({ now: Date.now() })` is not — the _value_ is non-deterministic.
If you need something the sandbox genuinely cannot provide — reading `process.env`, hitting a real clock, calling out — use `LocalActivity` with `proxyLocalActivities` from `@temporalio/workflow`: it runs outside the sandbox like a normal activity, but with lower overhead than a full network round-trip.

Logging via `console.log` is fine in workflow code (`@temporalio/workflow` patches it through Temporal's logger). `console.log({ now: Date.now() })` is fine too — per the first table, that value is workflow time and is stable across replays. What is _not_ fine is `console.log({ env: process.env.REGION })`, because that value can differ between the worker that ran the workflow and the worker that replays it.

## Canonical examples

Expand Down
5 changes: 5 additions & 0 deletions .changeset/testing-test-rig-export.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@temporal-contract/testing": minor
---

New `@temporal-contract/testing/test-rig` subpath: `testRig` builds the `TypedWorker` + `TypedClient` pair an in-process test needs against a `TestWorkflowEnvironment`, and registers an `onTestFinished` hook that replays every execution the client started — real replay-determinism coverage per test, with no separate replay pass to remember. Options include `replaySkipAllowlist`, a caller-supplied map of workflow-ID prefixes (with reasons) to skip when their execution is deliberately left non-terminal; it defaults to `{}` so a published rig never bakes in this repo's own fixture IDs. Also exports `isTerminalStatus`, `skipReasonFor`, and `extractStartedWorkflowId` for unit-testing the rig's own assumptions.
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ This file is the source of truth for agent guidance in this repo. `CLAUDE.md` an

## The 6 rules that prevent broken PRs

1. **Workflow code is deterministic.** No `Date.now()`, `Math.random()`, `setTimeout`, `crypto.randomUUID()`, native I/O, or `process.env` reads inside `declareWorkflow`'s `implementation`. Use `@temporalio/workflow` primitives (`sleep`, `uuid4`, the patched `Date`) or push the side effect into an activity. See [.agents/rules/workflow-determinism.md](.agents/rules/workflow-determinism.md). This is the #1 cause of broken Temporal workflows — read that file before touching workflow code.
1. **Workflow code is deterministic — and the sandbox enforces most of it for you.** `Date.now()`, `new Date()`, `Math.random()`, and `setTimeout` are **patched** in the sandbox and are replay-safe; `WeakRef`/`FinalizationRegistry` throw `DeterminismViolationError`; `crypto.*`, `fetch`, and native I/O aren't in the sandbox context at all. Prefer `@temporalio/workflow`'s `sleep` / `uuid4` / `workflowInfo()` anyway, because the patched APIs don't mean what their names suggest (`Date.now()` is _workflow_ time; `setTimeout` is a _durable timer_). The genuinely unprotected hazards inside `declareWorkflow`'s `implementation` are **`process.env` reads, module-level mutable state, and `import.meta`** — spend your vigilance there. See [.agents/rules/workflow-determinism.md](.agents/rules/workflow-determinism.md) before touching workflow code.
2. **Activities and the typed client return `AsyncResult<T, E>` from `unthrown`.** Never throw — wrap technical errors in `ApplicationFailure` and surface them via `ErrAsync(...)` (or `fromPromise(promise, qualify)`, where `qualify` returns the modeled error `E`). `OkAsync(value)`/`ErrAsync(error)` are the canonical pre-lifted constructors (there is no lowercase `okAsync`/`errAsync`); lifting an existing sync `Result` with `.toAsync()` stays valid but is not used for direct construction in this codebase — prefer `OkAsync()` zero-arg over `OkAsync(undefined)`. The client uses unthrown's `Result` for sync returns. unthrown adds a third **`defect`** channel for _unanticipated_ failures — a thrown exception the code didn't model surfaces as a defect (inspectable via `result.isDefect()` / `result.cause`, re-thrown at the edge), not a typed `err`. Narrow before reaching `.value`/`.error`/`.cause` — both the `r.isOk()` method and the `isOk(r)` free function are type guards (same for `isErr`/`isDefect`); the codebase uses the methods. Error classes are built with `TaggedError("@temporal-contract/Name", { name: "Name" })<{ ...payload }>` — the `_tag` is package-namespaced to avoid collisions, while `options.name` keeps `Error.name` the bare class name for readable logs. The worker's `ValidationError` subclasses are the exception — they must stay `ApplicationFailure` for Temporal's terminal-failure semantics. There is no `neverthrow`, no `@swan-io/boxed`, and no `@temporal-contract/boxed` package — those were removed.
3. **No `any`.** Use `unknown` and narrow. Enforced by oxlint.
4. **`.js` extensions in every import.** TypeScript files import each other as `./foo.js`, never `./foo` or `./foo.ts`. Required by ESM module resolution.
Expand Down
Loading
Loading