temporalio / temporalio/sdk-typescript
[Bug] Native Temporal (Node 26+, ECMAScript 2026) is not made deterministic inside workflow sandboxes
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 917
- Forks
- 224
- Avg merge
- 3d 16h
- Merged PRs (30d)
- 43
Description
What are you really trying to do?
Use the native ECMAScript Temporal API — now built into V8/Node rather than a userland polyfill — inside workflow code, the same way Date is used today.
Describe the bug
overrideGlobals() patches Date (constructor and .now), Math.random, setTimeout/clearTimeout, WeakRef, and FinalizationRegistry so that workflow code reading "the current time" or "a random number" gets a value tied to the activating workflow's own replay-consistent state. It does not patch anything under Temporal.
That's fine so long as Temporal isn't present in the sandbox at all — which was true everywhere until recently. It's a real gap now that Node ships it. Temporal (the language feature) reached TC39 Stage 4 in March 2026 and became part of ECMAScript 2026; Node 26 (released May 2026) ships it enabled by default, as a genuine language-level global — not a Node/platform API — so it's present in any V8 context, including a bare vm.createContext() with no explicit injection.
Temporal.Now.* reads the system clock directly rather than going through the patchable Date.now. Confirmed by pinning Date.now to a fixed value and observing Temporal.Now.instant() completely ignore it:
const originalDateNow = Date.now;
Date.now = () => 12345;
console.log(Date.now()); // 12345
console.log(Temporal.Now.instant().epochMilliseconds); // real wall-clock time, unaffected
So any workflow code that calls Temporal.Now.instant() (or .zonedDateTimeISO(), .plainDateISO(), etc. — everything under Temporal.Now reads the current moment) gets the real wall-clock time on every call, both during original execution and during replay, with no deterministic substitution. Unlike Date.now() misuse, which is at least covered by the existing override, this one has no patch point at all today.
This is a silent correctness bug rather than a loud one: it can produce a DeterminismViolationError if the divergent value flows into a command, or — worse — no visible error at all if it doesn't happen to change the command sequence, in which case the workflow is simply relying on non-replayable state without anyone noticing.
Minimal Reproduction
The underlying gap, with no Temporal SDK needed — reproducible in plain Node 26:
// node --version => v26.x
const originalDateNow = Date.now;
Date.now = () => 12345;
console.log('patched Date.now():', Date.now());
console.log('Temporal.Now.instant().epochMilliseconds:', Temporal.Now.instant().epochMilliseconds);
Also confirmed against the SDK source directly (@temporalio/workflow@1.21.1, currently latest): global-overrides.ts/.js has zero references to Temporal anywhere.
And the actual consequence — a real workflow whose replay fails with DeterminismViolationError, using a TestWorkflowEnvironment and Worker.runReplayHistory() to force a fresh module instantiation the same way a worker restart or sticky-cache eviction would in production:
// workflows.mjs
import { sleep } from '@temporalio/workflow';
export async function nativeTemporalNowWorkflow(thresholdEpochMs) {
const now = Temporal.Now.instant().epochMilliseconds;
if (now < thresholdEpochMs) {
await sleep(100);
return 'before-threshold';
}
await sleep(100);
await sleep(100);
return 'after-threshold';
}
// driver.mjs — node --version => v26.x
import { TestWorkflowEnvironment } from '@temporalio/testing';
import { Worker } from '@temporalio/worker';
import { v7 as uuidv7 } from 'uuid';
const workflowsPath = new URL('./workflows.mjs', import.meta.url).pathname;
const env = await TestWorkflowEnvironment.createLocal();
const taskQueue = 'repro';
const worker = await Worker.create({
connection: env.nativeConnection,
namespace: env.namespace,
taskQueue,
workflowsPath,
});
const workerRun = worker.run();
const workflowId = uuidv7();
const thresholdEpochMs = Date.now() + 1500; // comfortably in the future for the *original* run
const handle = await env.client.workflow.start('nativeTemporalNowWorkflow', {
args: [thresholdEpochMs],
taskQueue,
workflowId,
});
console.log('original execution result:', await handle.result());
await new Promise((r) => setTimeout(r, 3000)); // let real wall-clock time pass before replaying
const history = await env.client.workflow.getHandle(workflowId).fetchHistory();
worker.shutdown();
await workerRun;
// Fresh module instantiation, same as a worker restart or sticky-cache eviction would force.
await Worker.runReplayHistory({ workflowsPath }, history, workflowId);
Output:
original execution result: before-threshold
[…] WARN temporalio_sdk_core::worker::workflow: Failing workflow task […]
failure="[TMPRL1100] Nondeterminism error: Timer machine does not handle this
event: HistoryEvent(id: 10, WorkflowExecutionCompleted)"
Uncaught DeterminismViolationError: Replay failed with a nondeterminism error.
This means that the workflow code as written is not compatible with the
history that was fed in. Details: […] "[TMPRL1100] Nondeterminism error:
Timer machine does not handle this event: HistoryEvent(id: 10,
WorkflowExecutionCompleted)" […]
The original run reads Temporal.Now.instant() immediately on start, well before thresholdEpochMs, takes the one-timer branch, and completes. Replay happens after the 3-second wait, so Temporal.Now.instant() — still reading the real clock, still unpatched — now returns a value past the threshold. Replay takes the two-timer branch and gets stuck expecting a second TimerStarted/TimerFired pair where the recorded history says the workflow already completed.
This is deterministic by construction (the threshold is picked to fall between when the original run and the replay happen), not a probabilistic race — it reproduces on every run. I initially tried using the native-Temporal-derived value directly as a timer duration and as a plain result value; neither triggered a violation, which suggests replay validates command type/count strictly but not every parameter value — worth knowing regardless of this specific bug, since it means some classes of hidden non-determinism could be silently tolerated rather than surfaced.
Environment/Versions
- Temporal TypeScript SDK: 1.21.1 (latest)
- Node.js: 26.5.0 (Temporal enabled by default)
- Relevant since: Node 26, released May 2026
Additional context
Blocks #2148 (adding Node 26 to the test matrix). Adding Node 26 to CI doesn't by itself surface this, since it only matters for workflow code that reaches for the now-native Temporal API, which existing tests likely don't — so treating #2148 as done without addressing this would give false confidence that Node 26 support is complete. Flagging here rather than after someone hits a replay failure in production.
A fix would look like extending overrideGlobals() to patch Temporal.Now the same way Date/Date.now are patched, routing its wall-clock reads through getActivator().now. Unlike Date, there's no "called with explicit arguments" escape hatch to preserve — everything under Temporal.Now is inherently "the current moment," so the whole namespace needs to route through the activator.
Short of that, even having the sandbox explicitly deny access to Temporal.Now (the same way WeakRef/FinalizationRegistry throw DeterminismViolationError on construction) would turn this from a silent bug into a loud one, which is strictly better than the status quo.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with global-overrides.ts, specifically overrideGlobals(), and review how Date and other nondeterministic globals are patched. Reproduce the issue with the Node 26 Temporal example, then use TestWorkflowEnvironment and Worker.runReplayHistory() to verify that a workflow using Temporal.Now replays consistently after the clock advances.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- node.js, typescript
- Domain
- backend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 55/100