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
4d275ba
Retain keyed signal receipts and refuse key reuse with different intent
rmcdaniel Oct 20, 2026
8a167b6
Reproduce retry intent collisions under reduced PHP float precision
rmcdaniel Oct 20, 2026
18909a4
Keep retry intent identity independent of PHP float precision
rmcdaniel Oct 20, 2026
5d08795
test: reproduce ignored control-plane run targets
rmcdaniel Oct 20, 2026
14acbfb
fix: preserve explicit control-plane run fences
rmcdaniel Oct 20, 2026
a6c2e65
test: require conflict refusals to preserve all durable rows
rmcdaniel Oct 20, 2026
6956502
fix: refuse conflicting command keys without durable writes
rmcdaniel Oct 20, 2026
7c98e1c
Test malformed and Unicode request key admission boundaries
rmcdaniel Nov 2, 2026
a14a8be
Validate Unicode command keys before admission and return typed refusals
rmcdaniel Nov 2, 2026
340cce0
Merge current main while preserving command key admission and interna…
rmcdaniel Nov 2, 2026
b52a5c1
Reproduce explicit empty request keys across all admission routes
rmcdaniel Nov 2, 2026
683b123
Reject explicit empty and non-string command keys before admission
rmcdaniel Nov 2, 2026
cad4d68
Simplify command intent dispatch and receipt response helpers
rmcdaniel Nov 2, 2026
71ba06c
Use concise ASCII digit class for versioned intent digests
rmcdaniel Nov 2, 2026
3180980
Document command-key validation and coordinated writer upgrades
rmcdaniel Nov 2, 2026
75cca6b
Preserve the existing request identity documentation anchor
rmcdaniel Nov 2, 2026
f814bf3
Keep product compatibility notes within the public documentation boun…
rmcdaniel Nov 2, 2026
587af85
Merge origin/main into claude/216-command-keys
rmcdaniel Nov 3, 2026
3c2f515
Merge origin/main into claude/216-command-keys
rmcdaniel Nov 3, 2026
10793e1
Merge origin/main into claude/216-command-keys
rmcdaniel Nov 3, 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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,15 @@
timers in a cancellation scope or parallel group are refused with
`unsupported_timer`. A timer step without both its scheduled and fired events
is refused with `incomplete_timer_step` (#864).
- Retain keyed signal admissions and compare canonical typed intent for keyed
signals and updates. Conflicting key reuse refuses without durable writes;
undecodable legacy receipts remain explicitly unverified. This strengthens
the earlier same-key update receipt contract (#877).
- Require valid UTF-8 caller keys of 1..255 code points before admission.
Explicit empty or non-string keys refuse; null clears an inherited key.
Apply the nullable `intent_digest` migration, drain and upgrade all embedded
database writers together, and allocate new keys for different operations.
Native Server and published SDK/CLI qualification remain separate.

## 2.17.1 - 2026-11-03

Expand Down
70 changes: 48 additions & 22 deletions docs/api-stability.md
Original file line number Diff line number Diff line change
Expand Up @@ -551,30 +551,56 @@ models should update app-owned projections at domain milestones and store
`workflow_id` plus `run_id` beside those business rows. See
[`docs/architecture/business-reporting-read-models.md`](architecture/business-reporting-read-models.md).

## Update Request Identity

An update caller key identifies a retained command within one logical workflow
instance, across update names and runs. A retry returns that original lifecycle
and does not replace its arguments. Omitted keys admit independent operations.
Server uses the body `request_id`; proxy correlation headers remain audit data.
Embedded webhook callers retain their existing `X-Request-Id` contract.
<a id="update-request-identity"></a>

## Signal and update command identity

In unreleased Core source, a caller key identifies a retained command within
one logical workflow instance and command type, across command names and runs.
A same-intent retry returns the original admission and lifecycle, including
after closure or continue-as-new while its receipt remains retained. Omitted
keys admit independent operations. A signal and an update have separate key
spaces. Embedded webhook callers retain their existing `X-Request-Id` contract.
This embedded contract does not advertise retry safety for a Server release.
Native service support requires separate published runtime and SDK/CLI
qualification.

Intent comparison includes the command name, typed arguments and target scope,
including an explicitly requested run. Reusing a key for different intent
returns `request_id_conflict` without recording another command, consuming a
sequence or changing the retained outcome. Integer, float and string values
remain distinct. A retained legacy receipt whose original intent cannot be
decoded returns `intent_verified: false`; it never authorizes a fresh command
or claims that the new intent matches. Once a receipt is pruned, its key no
longer provides duplicate protection. Activity effects still require their own
application or provider idempotency.

Caller keys must be valid UTF-8 strings containing 1..255 code points, with no
normalization or truncation. Case and canonically equivalent Unicode spellings
remain distinct. Explicit null clears an inherited key; an omitted control-plane
option preserves it. Explicit empty, non-string, malformed and oversized keys
are refused before admission. Embedded calls throw
`InvalidCommandRequestIdException`; control-plane signal/update returns a
nonrecorded 422 result with validation errors and no command identity.

The logical key is available through `WorkflowCommand::requestId()` and command
context. New update rows store a bounded `update-v1:` lookup token in the
existing `workflow_commands.request_id` column. Consumers must not interpret
that internal indexed value as caller text. Exact logical comparison preserves
opaque case and long Unicode keys regardless of database collation. Legacy
Server body keys remain readable from `context.server.metadata.request_id`,
even when the old indexed column contains a proxy header. Existing rows are
never rewritten. The earliest retained receipt wins if old code admitted more
than one command for a caller key.

Upgrade every process that writes directly to the database together before
admitting new updates with this storage format. Drain and restart old embedded
workers and Server processes; do not roll back to an old writer after new
update rows are admitted. Remote SDK clients do not write this column and do
not need an upgrade for this change. Native takeover must qualify the new
representation separately.
context. New signal/update rows store bounded `signal-v1:` / `update-v1:` lookup
tokens in `workflow_commands.request_id`. Consumers must not interpret that
internal indexed value as caller text. Exact logical comparison preserves
opaque keys regardless of database collation. Legacy Server body keys remain
readable from `context.server.metadata.request_id`, even when the old indexed
column contains a proxy header. Proxy correlation is not a command key. The
earliest retained receipt wins if old code admitted several for one caller key.

Before upgrading writers, apply the additive migration that creates nullable
`workflow_commands.intent_digest`. It performs no backfill or row rewrite;
legacy intent is compared on read when recoverable. Drain and upgrade every
process that writes directly to the workflow database together before admitting
new keyed signals or updates. Mixed old/new writers and rollback to an old
writer after admission are unsupported. Existing callers that reused a key for
different intent must allocate a new key for a new operation; oversized keys
must also be replaced before upgrade. Remote SDK clients do not write these
columns. Native takeover must qualify the new representation separately.

Update intake limits are checked before requiring a local executable workflow
definition. A rejected update remains inspectable with
Expand Down
80 changes: 80 additions & 0 deletions src/V2/CommandResult.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,94 @@
use Workflow\V2\Enums\CommandOutcome;
use Workflow\V2\Enums\CommandStatus;
use Workflow\V2\Models\WorkflowCommand;
use Workflow\V2\Support\CommandRequestIdentity;

class CommandResult
{
protected bool $retained = false;

protected bool $retainedIntentVerified = true;

public function __construct(
protected readonly WorkflowCommand $command,
) {
}

/**
* The caller's command key for keyed signals and updates.
*
* @api Stable v2 command result identity API.
*/
public function requestId(): ?string
{
return $this->command->requestId();
}

/**
* Whether this result replays a receipt retained from an earlier admission
* with the same caller key, rather than a new admission.
*/
public function retained(): bool
{
return $this->retained;
}

/**
* False only for a retained legacy receipt whose intent could not be
* compared because its arguments are no longer decodable.
*/
public function intentVerified(): bool
{
return ! $this->retained || $this->retainedIntentVerified;
}

public function requestIdConflict(): bool
{
return $this->command->rejection_reason === CommandRequestIdentity::CONFLICT_REASON;
}

/**
* @return array{retained_command_id: string|null, retained_command_type: string|null, retained_outcome: string|null, conflict: list<string>}|null
*/
public function conflict(): ?array
{
if (! $this->requestIdConflict()) {
return null;
}

$values = $this->command->payloadValues([
'retained_command_id',
'retained_command_type',
'retained_outcome',
'conflict',
]);
$parts = $values['conflict'] ?? [];

return [
'retained_command_id' => is_string(
$values['retained_command_id'] ?? null
) ? $values['retained_command_id'] : null,
'retained_command_type' => is_string(
$values['retained_command_type'] ?? null
) ? $values['retained_command_type'] : null,
'retained_outcome' => is_string($values['retained_outcome'] ?? null) ? $values['retained_outcome'] : null,
'conflict' => is_array($parts) ? array_values(array_filter($parts, 'is_string')) : [],
];
}

public function asRetainedReceipt(bool $intentVerified): static
{
$clone = clone $this;
$clone->retained = true;
$clone->retainedIntentVerified = $intentVerified;

return $clone;
}

/**
* For a request-key conflict this identifies the retained command that
* refused the new intent; no new command is recorded for that response.
*/
public function commandId(): string
{
return $this->command->id;
Expand Down
4 changes: 4 additions & 0 deletions src/V2/Contracts/WorkflowControlPlane.php
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ public function redrive(string $instanceId, string $failedRunId, array $options
* Options:
* - arguments: array<int, mixed> — positional signal arguments
* - namespace: string|null — require the workflow instance to belong to this namespace
* - request_id: string|null — the caller's command key; a retry with the same key and the same
* signal returns the retained admission, reuse with a different signal is refused (409)
* - command_context: \Workflow\V2\CommandContext|null — recorded command attribution/context
* - strict_configured_type_validation: bool — fail closed when a configured workflow type mapping is now invalid
*
Expand Down Expand Up @@ -136,6 +138,8 @@ public function query(string $instanceId, string $name, array $options = []): ar
* Options:
* - arguments: array<int|string, mixed> — positional or named update arguments
* - namespace: string|null — require the workflow instance to belong to this namespace
* - request_id: string|null — the caller's command key; a retry with the same key and the same
* update returns the retained receipt, reuse with a different update is refused (409)
* - command_context: \Workflow\V2\CommandContext|null — recorded command attribution/context
* - wait_for: 'accepted'|'completed'|null — accepted-only submit or completion wait
* - wait_timeout_seconds: int|null — completion wait timeout
Expand Down
1 change: 1 addition & 0 deletions src/V2/Enums/CommandOutcome.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ enum CommandOutcome: string
case RejectedUnknownSignal = 'rejected_unknown_signal';
case RejectedUnknownUpdate = 'rejected_unknown_update';
case RejectedInvalidArguments = 'rejected_invalid_arguments';
case RejectedRequestIdConflict = 'rejected_request_id_conflict';
case RejectedCompatibilityBlocked = 'rejected_compatibility_blocked';
case RejectedWorkflowDefinitionUnavailable = 'rejected_workflow_definition_unavailable';
}
25 changes: 25 additions & 0 deletions src/V2/Exceptions/InvalidCommandRequestIdException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

namespace Workflow\V2\Exceptions;

use InvalidArgumentException;

final class InvalidCommandRequestIdException extends InvalidArgumentException
{
public function __construct()
{
parent::__construct('The request ID must be a valid UTF-8 string containing 1 to 255 Unicode characters.');
}

/**
* @return array<string, list<string>>
*/
public function validationErrors(): array
{
return [
'request_id' => [$this->getMessage()],
];
}
}
46 changes: 28 additions & 18 deletions src/V2/Models/WorkflowCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,12 @@
use Workflow\V2\Enums\CommandStatus;
use Workflow\V2\Enums\CommandType;
use Workflow\V2\Support\BridgeCommandIdentity;
use Workflow\V2\Support\CommandRequestIdentity;
use Workflow\V2\Support\CommandSequence;
use Workflow\V2\Support\ConfiguredV2Models;
use Workflow\V2\Support\ExternalPayloads;
use Workflow\V2\Support\MessageStreamCursor;
use Workflow\V2\Support\ServiceCallEffectIdentity;
use Workflow\V2\Support\UpdateRequestIdentity;

class WorkflowCommand extends Model
{
Expand Down Expand Up @@ -371,8 +371,8 @@ public function requestFingerprint(): ?string

public function requestId(): ?string
{
if ($this->command_type === CommandType::Update) {
return UpdateRequestIdentity::fromCommand($this);
if (CommandRequestIdentity::supports($this->command_type)) {
return CommandRequestIdentity::fromCommand($this);
}

if (is_string($this->request_id ?? null) && $this->request_id !== '') {
Expand All @@ -386,6 +386,17 @@ public function requestId(): ?string
: null;
}

/**
* Canonical intent recorded at admission for keyed signals and updates.
* Receipts recorded before intent digests existed return null.
*/
public function intentDigest(): ?string
{
$digest = $this->intent_digest ?? null;

return is_string($digest) && $digest !== '' ? $digest : null;
}

public function requestedRunId(): ?string
{
if (is_string($this->requested_workflow_run_id ?? null) && $this->requested_workflow_run_id !== '') {
Expand Down Expand Up @@ -505,7 +516,10 @@ public function payloadValues(array $keys): array
private static function withRequestIdentity(WorkflowInstance $instance, array $attributes): array
{
$commandType = $attributes['command_type'] ?? null;
$isUpdate = $commandType === CommandType::Update || $commandType === CommandType::Update->value;
$keyedType = $commandType;
if (! $keyedType instanceof CommandType) {
$keyedType = is_string($commandType) ? CommandType::tryFrom($commandType) : null;
}
$usesRequestId = $commandType === CommandType::Redrive
|| $commandType === CommandType::Redrive->value;
$context = is_array($attributes['context'] ?? null) ? $attributes['context'] : [];
Expand All @@ -528,22 +542,18 @@ private static function withRequestIdentity(WorkflowInstance $instance, array $a
}
}

if ($isUpdate) {
if ($serviceCallIdentity instanceof ServiceCallEffectIdentity) {
$attributes['request_id'] = $serviceCallIdentity->storageToken();
} else {
$context['request'] = is_array($context['request'] ?? null) ? $context['request'] : [];
$context['request']['request_id'] = UpdateRequestIdentity::fromContext($context);
$attributes['context'] = $context;
$attributes['request_id'] = UpdateRequestIdentity::storageToken($instance, $context);
}
} elseif ($serviceCallIdentity instanceof ServiceCallEffectIdentity && in_array($commandType, [
CommandType::Start,
CommandType::Start->value,
CommandType::Signal,
CommandType::Signal->value,
if ($serviceCallIdentity instanceof ServiceCallEffectIdentity && in_array($commandType, [
CommandType::Start, CommandType::Start->value,
CommandType::Signal, CommandType::Signal->value,
CommandType::Update, CommandType::Update->value,
], true)) {
$attributes['request_id'] = $serviceCallIdentity->storageToken();
} elseif ($keyedType instanceof CommandType && CommandRequestIdentity::supports($keyedType)) {
// Caller keys remain independent of bridge/service-call identities.
$context['request'] = is_array($context['request'] ?? null) ? $context['request'] : [];
$context['request']['request_id'] = CommandRequestIdentity::fromContext($context, $keyedType);
$attributes['context'] = $context;
$attributes['request_id'] = CommandRequestIdentity::storageToken($instance, $keyedType, $context);
} elseif ($usesRequestId) {
$request = is_array($attributes['context']['request'] ?? null)
? $attributes['context']['request']
Expand Down
Loading
Loading