Versioning
Replay before changing builds
Before promoting a candidate, replay representative open and recently closed histories against its exact application code. Before rollback, replay histories written by the newer build against the older artifact. Follow the replay testing gate; a compatible routing cohort or successful integrity check alone does not prove replay compatibility.
Since workflows can run for long periods, sometimes months or even years, it's common to need to make changes to a workflow definition while executions are still in progress. Without versioning, modifying workflow code that affects the execution path would cause non-determinism errors during replay.
The getVersion() helper function allows you to safely introduce changes to running workflows by creating versioned branch points.
Evolving Recorded Entry Arguments
A run keeps the arguments recorded when it started. Deploying a new definition does not rewrite that input. On replay, the runtime binds those recorded values to today's workflow entry method before any code inside that method runs. A getVersion() or patched() branch inside the body cannot rescue an incompatible signature: the runtime has to enter the body first.
Keep existing positional arguments in the same order and accept their recorded types. A PHP default fills an omitted argument; it does not replace an incompatible value already recorded at that position. For example, a run started with [7] still supplies the integer 7 after deployment:
| Entry change | Effect on the recorded input [7] |
|---|---|
handle(int $amount) → handle(int $amount, string $currency = 'USD') | The old argument still binds; the new trailing argument can use its default. Keep replayed commands compatible too. |
| Add a required trailing argument without a default | The old run has no value for it. |
| Insert, remove or reorder leading arguments | Existing values can move into the wrong parameter. Do not assume matching parameter names repair positional input. |
Change int $amount to string $amount | The recorded integer has not become a string. |
Change int $amount to PaymentRequest $request | Dependency injection is not an input migration. A newly constructed DTO must not replace the recorded amount with constructor defaults. |
| Remove only a trailing parameter | PHP can accept the call, but verify that dropping its meaning does not change replayed commands or business behavior. |
Prefer an additive, optional trailing parameter or a new workflow type for a different input contract. If you need a new DTO internally, construct it explicitly from compatible recorded arguments inside the workflow body and version any change to durable commands there. Keep code able to replay every supported input shape until those runs no longer need it. Test the actual stored inputs against the new definition, including a cold replay after a durable wait; testing only new starts misses this deployment boundary.
Workflow 2.7.2 Behavior
In Workflow 2.7.2, an entry arity or type error can become a terminal WorkflowFailed event. Some dependency-resolution errors instead leave the run open with a repeatedly failing task. A scalar changed to a newly injected DTO with default constructor values can even run with the wrong data. These are product defects tracked in Workflow #866, not a safe versioning strategy.
Restoring compatible code can recover an open run that can be redelivered, but it does not reopen a run already made terminal. Neither repair() nor a new version branch promises to revive such a failed original. Inspect its actual status and failure first; preserve its input and history while deciding on recovery.
Released Embedded Safeguard
Workflow 2.7.3 validates recorded entry arguments before entering application code. Incompatible binding becomes a typed WorkflowEntryArgumentsMismatchException and a replay-blocked workflow task, with replay_blocked_reason = workflow_entry_arguments_mismatch; the run stays nonterminal. Diagnostic fields identify the workflow, entry method, parameter and expected/actual types without copying argument values. A newly injected DTO that would discard recorded input is refused too. Check the actual Workflow package installed in the embedded application. This safeguard does not reopen terminal runs created by an older release.
With that released safeguard, recover the same still-open original as follows:
- Inspect the selected run's task diagnostic and confirm
workflow_entry_arguments_mismatch, rather than assuming every task failure has this cause. - Restore a definition that accepts the original recorded arguments, or deploy a compatible adapter in the workflow body. Keep the original workflow identity, input, history and deadlines; do not edit storage to make the new signature fit.
- Use the supported repair action for that same still-open run after compatible code is available, then verify its original work resumes and its terminal outcome is recorded once. Repair does not extend the original deadline; a deadline that has elapsed still applies.
The safeguard does not reinterpret exceptions thrown by the workflow body as deployment mismatches. A genuine application TypeError or other unhandled body failure still follows normal workflow failure behavior. It also does not supply the fleet-level legacy-branch retirement check tracked in shared #220.
Service SDKs Have Separate Boundaries
An embedded Core fix does not change the SDK process that executes service-mode workflow code. The following older published SDKs predate the service entry safeguard tracked in shared #221:
| Published SDK | Entry-binding behavior |
|---|---|
| PHP 2.5.3 | Calls the registered handler with context followed by recorded positional input. An entry ArgumentCountError or TypeError can follow the generic workflow-failure path. |
| Python 2.6.1 | Calls run(ctx, *start_input). Changed arity can fail the workflow; type annotations alone do not enforce argument types or convert recorded values. Generator-body errors are application failures too. |
| Rust 3.5.5 | The typed adapter deserializes input before calling its fixed-signature handler. A changed request type can fail that decoding step. This differs from PHP/Python positional binding; an incidental unconsumed-history refusal is not a promised recoverable signature safeguard. |
Keep service input compatibility and actual SDK qualification separate from the embedded release. Do not catch every TypeError or every Rust handler error and label it replay-safe: that would hide real application failures.
Service Entry Safeguard
Released support. Server 2.9.9 and PHP SDK 2.5.4, Python SDK 2.6.2 and Rust SDK 3.5.6 introduced the entry safeguards and routing/recovery behavior below. These are minimum feature versions; install current stable releases for security fixes.
The prepared SDKs validate recorded entry input before application workflow code runs. Incompatible input reports workflow_entry_arguments_mismatch as a failed, replay-blocked workflow task, without issuing fail_workflow or closing the business run. Input, workflow/run identity, committed history, build pin and original deadlines remain unchanged. PHP validates required positional arguments and declared PHP types. Python uses signature binding for arity and defaults; annotations do not become runtime type checks. Rust's typed adapters validate input decoding before calling the handler or its state factory. Raw Rust handlers remain responsible for their own application input handling.
The Server's specific task failure_reason is workflow_entry_arguments_mismatch; replay_blocked_reason remains the generic worker_reported_replay_failure. Inspect latest_workflow_task_failure.reason in the selected run's debug response. Do not confuse these fields with the embedded Core diagnostic above. Successfully bound application code that throws a TypeError, returns a Rust handler error or otherwise fails still follows ordinary business-failure behavior.
Restore code that accepts the original recorded input, then use the supported same-original recovery procedure. Do not replace input with DTO defaults, alter history or reset a deadline. This safeguard does not reopen an original that an older worker already made terminal.
Safe replacement routing also needs the entry-validation worker capability. A changed fingerprint is neither proof of replay compatibility nor a reason to rewrite a run's original fingerprint. Version durable-command changes inside a compatible entry method and test cold replay before promotion.
Evolving Activity Arguments and Results
An activity has two stored contracts: the arguments recorded when it was scheduled and the result recorded when it completed. A deployment does not migrate either. This applies to embedded PHP and service workers using the PHP, Python and Rust SDKs.
A pending attempt or retry receives the original scheduled arguments. It runs the activity implementation selected by the worker's registration and build-routing policy, which may still be an older build. Deploying a new activity implementation does not guarantee every attempt uses it, and does not update queued arguments.
When a completed activity is replayed, the current workflow code receives its recorded result without running the activity again. Matching an activity step and type is not a schema check of its arguments or result. A changed payload shape can therefore fail application code without a history mismatch.
Add Optional Input Without Reinterpreting Old Values
Keep existing positional arguments and their meaning. In embedded PHP, an optional trailing parameter lets an old scheduled input such as [7] continue to bind:
use Workflow\V2\Activity;
final class PriceActivity extends Activity
{
public function handle(int $amount, string $currency = 'USD'): array
{
return ['amount' => $amount, 'currency' => $currency];
}
}
The default fills a missing argument. It does not convert an incompatible recorded value or repair a reordered argument. Keep the activity's external effects compatible too: a retry can happen after the deployment and must remain idempotent.
PHP service handlers receive their activity context followed by recorded positional arguments. Python handlers receive the recorded positional arguments directly. Both need defaults for omitted arguments. Python annotations alone do not validate or convert values. Rust typed activities deserialize one request value; add an optional field or a field with an explicit Serde default while preserving the existing representation. A newly required field can fail decoding before the Rust handler runs. See the PHP, Python and Rust SDK guides for their activity APIs.
Read Older Results With a Fallback
When a new activity result adds currency, workflows must still accept older completed results that contain only amount:
$result = activity(PriceActivity::class, 7);
$currency = $result['currency'] ?? 'USD';
Choose a fallback that preserves the old business meaning. Retain it for every history that may be replayed, including queries and recovery. Deploy compatible readers before writers that emit a changed representation, and verify rollback readers against newly recorded results too. A default on the activity's input does not add a field to an already recorded result.
Use a New Type for a Different Contract
For a renamed field, a different representation or a changed business meaning, keep the old activity type available and introduce a new one. Use a workflow getVersion() branch at the scheduling call when old histories must keep their old activity step and new runs should schedule the new type. A branch inside the activity cannot fix arguments that fail before its body runs, and a version marker does not rewrite results already in history.
Test two cold deployment boundaries against the actual installed packages: schedule with old code and execute with candidate activity code; then complete with old activity code and replay with candidate workflow code. Include incompatible inputs and results as failure controls. Do not assume the workflow-entry safeguards above also protect activity signatures or application result access. The confirmed embedded activity DTO defect is tracked in Workflow #910.
Verified Failure Boundaries
The published-package checks in #363 used fresh processes across each deployment boundary. These are observed baselines, not minimum supported versions:
| Published package | Incompatible activity input | Incompatible result reader |
|---|---|---|
| Embedded Workflow 2.11.2 | A missing required argument produces ArgumentCountError and an activity failure. | Accessing a missing array field under Laravel error handling produces ErrorException and workflow failure. |
| PHP SDK 3.2.0 with Server 2.11.0 | A missing required positional argument is reported as ArgumentCountError in the activity failure. | Passing a missing field to a required string reader produces TypeError and workflow failure. |
| Python SDK 2.9.0 with Server 2.11.0 | A missing required positional argument produces TypeError and an activity failure. | Indexing a missing result key produces KeyError and workflow failure. |
| Rust SDK 4.2.2 with Server 2.11.0 | A newly required request field fails typed input decoding before the activity handler runs. | A newly required result field fails typed result decoding in the workflow. |
The service checks recorded max_attempts = 1; the unhandled activity failure then failed the workflow. In general, activity failures follow their recorded retry and timeout policy. A workflow can handle an exhausted activity failure. A result-reader error is an application workflow failure, not a request to rerun the completed activity. All additive controls completed with the original amount 7 and default currency USD, with one activity attempt and no repairs. In result controls, the completed activity stayed completed.
Embedded Workflow 2.11.2 also accepts a changed activity signature that replaces recorded scalar 7 with an injected DTO whose default amount is 0, and can incorrectly complete with 0. Workflow #910 tracks that product defect. Do not use dependency injection as an activity-input migration or assume the workflow-entry safeguard covers it. Restoring compatible code does not reopen an already terminal run; inspect the actual run and follow the recovery guide without changing recorded input, history or deadlines.
Review recorded workflow start arguments and the signal payload evolution recipe alongside these activity contracts.
Released embedded activity guard
Workflow 2.11.3 is the fixed minimum for the embedded PHP guard against activity binding that displaces recorded arguments. In queued and local activity paths, replacing recorded scalar 7 with an injected DTO whose default is 0 is refused before the activity body runs, with ActivityEntryArgumentsMismatchException. The published, installed cold-process receipt verifies refusal and recovery with the original input. The older 2.11.2 behavior above remains affected. This guard does not add fields to older activity results; compatible readers still need their fallback.
The refusal follows the activity's recorded attempt, backoff and timeout policy. With one allowed attempt, it exhausts the activity and an unhandled failure fails the workflow. If authored attempts remain and compatible code returns before the permitted retry and deadlines, the same original run/activity can complete with its recorded input. The receipt verifies two authored attempts with zero backoff and output 7; it does not promise an indefinite hold for a future deployment or grant extra attempts.
Restoring code or installing the package does not reopen terminal originals, reset deadlines or restore consumed attempts. Inspect the actual failure and preserve recorded input/history. Check the Workflow package installed in the embedded application: this library release does not update existing Server images or establish service-worker/native acceptance.
Using getVersion
getVersion() is a replay-safe straight-line helper. Each change point records a durable version marker for the run on first execution, and every later replay reuses that committed value instead of recalculating the branch from today's code. Old runs that predate a newly introduced branch point conservatively receive DEFAULT_VERSION.
use Workflow\V2\Workflow;
use Workflow\V2\WorkflowStub;
use function Workflow\V2\{activity, getVersion};
final class MyWorkflow extends Workflow
{
public function handle(): void
{
$version = getVersion(
'my-change-id',
WorkflowStub::DEFAULT_VERSION,
1
);
if ($version === WorkflowStub::DEFAULT_VERSION) {
activity(OldActivity::class);
} else {
activity(NewActivity::class);
}
}
}
How It Works
The getVersion() method takes three parameters:
- changeId - A unique identifier for this change point
- minSupported - The minimum version this code still supports
- maxSupported - The maximum (current) version for new executions
When a workflow encounters getVersion():
- New executions append a typed
VersionMarkerRecordedhistory event with thechange_id, chosenversion, and supported range, then returnmaxSupported - Replaying executions return the previously recorded version from that history marker
- Runs without a marker yet fall back to
WorkflowStub::DEFAULT_VERSIONwhen the runtime determines the branch predates the current workflow definition. It first checks the run's compatibility marker against the current worker, then compares the run's durably snappedworkflow_definition_fingerprintto the current loadable workflow class. If the fingerprints match, the run is on the same definition and the marker is recorded fresh; if they differ, the run predates the change and the fallback fires. Runs whoseWorkflowStartedhistory predates the fingerprint snapshot (no recorded fingerprint) conservatively receiveDEFAULT_VERSIONbecause the runtime cannot prove the definition has not changed. This conservative fallback is the frozen 2.0 policy for pre-fingerprint runs: the runtime does not block claim solely because an older run lacks a recorded fingerprint. The fallback does not append a synthetic marker or consume a new workflow step - Query methods replay the same committed version marker before invoking the annotated method, so queries see the same branch the workflow task saw
- Waterline exposes recorded markers in the selected-run timeline with
version_change_id,version,version_min_supported, andversion_max_supported, and selected-run detail now also surfacesworkflow_definition_fingerprint,workflow_definition_current_fingerprint, andworkflow_definition_matches_currentso operators can see when a long-lived run started on an older definition even before a version marker was committed
This allows new workflows to use the latest code path while existing workflows continue using their original path.
Adding a New Version
Suppose you have an existing workflow that calls prePatchActivity:
use Workflow\V2\Workflow;
use function Workflow\V2\activity;
final class MyWorkflow extends Workflow
{
public function handle()
{
$result = activity(PrePatchActivity::class);
return $result;
}
}
To replace it with postPatchActivity without breaking running workflows:
use Workflow\V2\Workflow;
use Workflow\V2\WorkflowStub;
use function Workflow\V2\{activity, getVersion};
final class MyWorkflow extends Workflow
{
public function handle()
{
$version = getVersion(
'activity-change',
WorkflowStub::DEFAULT_VERSION,
1
);
$result = $version === WorkflowStub::DEFAULT_VERSION
? activity(PrePatchActivity::class)
: activity(PostPatchActivity::class);
return $result;
}
}
When you roll out a deployment that introduces a new getVersion() branch point, keep rotating DW_V2_CURRENT_COMPATIBILITY for that build wave. The runtime uses the run's start-time workflow_definition_fingerprint as its primary authority when deciding whether a missing version marker belongs to a fresh execution or to an older run that should stay on DEFAULT_VERSION. The compatibility marker still matters for compatibility-aware worker routing and fires before the fingerprint check. Runs that predate the fingerprint snapshot (no recorded fingerprint) conservatively stay on DEFAULT_VERSION since the runtime cannot verify the definition has not changed.
Adding More Versions
When you need to make additional changes, increment maxSupported:
$version = getVersion(
'activity-change',
WorkflowStub::DEFAULT_VERSION,
2
);
$result = match($version) {
WorkflowStub::DEFAULT_VERSION => activity(PrePatchActivity::class),
1 => activity(PostPatchActivity::class),
2 => activity(AnotherPatchActivity::class),
};
Deprecating Old Versions
After all workflows using an old version have completed, you can drop support by increasing minSupported. This removes the need to maintain old code paths.
// After all DEFAULT_VERSION workflows have completed:
$version = getVersion(
'activity-change',
1, // No longer supporting DEFAULT_VERSION
2
);
$result = match($version) {
1 => activity(PostPatchActivity::class),
2 => activity(AnotherPatchActivity::class),
};
If a workflow with a version older than minSupported tries to replay, it will throw a VersionNotSupportedException. That includes older-compatibility runs whose safe fallback is still WorkflowStub::DEFAULT_VERSION.
If you accidentally reuse the same changeId for a different branch point later, the runtime treats that as a determinism error instead of silently accepting the mismatch. Keep one stable changeId per logical code change.
Multiple Change Points
You can use multiple getVersion() calls in the same workflow for independent changes:
use Workflow\V2\Workflow;
use Workflow\V2\WorkflowStub;
use function Workflow\V2\getVersion;
final class MyWorkflow extends Workflow
{
public function handle(): void
{
$version1 = getVersion('change-1', WorkflowStub::DEFAULT_VERSION, 1);
$version2 = getVersion('change-2', WorkflowStub::DEFAULT_VERSION, 1);
// Each change point is tracked independently
}
}
Important: Each changeId should be unique within a workflow. The chosen version is recorded as typed workflow history and replayed deterministically on later workflow tasks, queries, and Waterline detail views. When Waterline shows no VersionMarkerRecorded entry for a change point on an older run, that means replay stayed on the legacy DEFAULT_VERSION path without backfilling a new marker into existing history. The selected-run detail fields workflow_definition_fingerprint, workflow_definition_current_fingerprint, and workflow_definition_matches_current tell you whether that run started on a different workflow definition than the one your current build can load today.
patched() Shorthand
patched($changeId) is a two-state shorthand for the common "did this run cross a one-time code change?" question. It records the same durable VersionMarkerRecorded history event as getVersion() and resolves to a boolean instead of an integer.
use Workflow\V2\Workflow;
use function Workflow\V2\{activity, patched};
final class MyWorkflow extends Workflow
{
public function handle(): string
{
if (patched('use-new-payment-activity')) {
return activity(NewPaymentActivity::class);
}
return activity(LegacyPaymentActivity::class);
}
}
Behavior:
- New runs commit the marker, take the new branch, and
patched()returnstrue. - Runs that started before this
changeIdwas added stay onDEFAULT_VERSIONunder the same fingerprint fallbackgetVersion()uses, andpatched()returnsfalse. - Replays of either kind read the previously committed marker and return the same value, so the branch decision is durable.
patched() is exactly equivalent to getVersion($changeId, DEFAULT_VERSION, 1) === 1. Use it when you only have two branches and you do not need to keep the legacy branch around forever — the boolean spelling reads cleaner at the call site than a match over DEFAULT_VERSION and 1.
If you need more than two branches, or you plan to add another version of the same logical change later, use getVersion() with an explicit maxSupported. Switching from patched() to getVersion() for the same changeId is a determinism error because the existing marker was recorded with maxSupported = 1.
Removing the Legacy Branch with deprecatePatch()
When every legacy run for a patched() change point has finished, you can delete the legacy branch from your workflow code. The compatible way to do that is to replace the patched() call with deprecatePatch() rather than removing the call entirely.
use Workflow\V2\Workflow;
use function Workflow\V2\{activity, deprecatePatch};
final class MyWorkflow extends Workflow
{
public function handle(): string
{
deprecatePatch('use-new-payment-activity');
return activity(NewPaymentActivity::class);
}
}
deprecatePatch() keeps the change point on the workflow timeline so already-committed VersionMarkerRecorded history events still match a known call, but it returns null and unconditionally takes the new path. Once you ship this version:
- New runs commit a
deprecate_patchmarker, take the new branch, and thedeprecatePatch()call returnsnull. - Existing runs that already committed a
patchedmarker for thischangeIdkeep replaying that marker and resolve through the deprecated branch — which is now the only branch — without erroring. - Existing runs that committed
falseforpatched()(the legacy path) are no longer in flight by assumption. If one is still running and tries to replay, the workflow will still read the legacy marker but execute the new branch — which is why the safe lifecycle is "wait for legacy runs to drain before deployingdeprecatePatch()".
Two-phase patch lifecycle and placement rules:
- Introduce
patched()at the change point. Both branches stay in the workflow code. New runs take the new branch; older runs keep taking the legacy branch. - Wait for every legacy run to finish. Use Waterline run search or
dw workflow:listto confirm there are no open runs that could replay through the legacy branch. - Replace
patched()withdeprecatePatch()at the same call site, with the samechangeId, and delete the legacy branch from the function body. Keep the call there — do not remove it — so durable history still matches a known change point. - Optional: remove
deprecatePatch()entirely once you are also confident no historical replay (queries, exports, audits) will ever read history that committed this marker. That is an irreversible step; most workflows can leavedeprecatePatch()in place indefinitely without paying any runtime cost.
Placement rules:
patched()anddeprecatePatch()must be called from the workflow function body, not from activities, signal handlers, or query methods. They are workflow steps and must replay deterministically.- Each
changeIdis one logical change point. Reusing the samechangeIdfor a different branch is a determinism error. - A
changeIdcan appear inpatched()ordeprecatePatch()form during its lifetime, but never both at the same time. The deploy that swaps the call must replace, not coexist. getVersion()andpatched()cannot coexist for the samechangeId. Pick one model when you introduce the change point.