@@ -2261,14 +2261,22 @@ private function attemptSignalInternal(
22612261
22622262 $ this ->loadLockedRunRelations ($ run , $ instance );
22632263
2264- if (! RunCommandContract::hasSignal ($ run , $ name )) {
2264+ $ signalAdmission = $ this ->signalAdmissionForRun ($ run , $ name );
2265+
2266+ if (($ signalAdmission ['allowed ' ] ?? false ) !== true ) {
22652267 $ command = $ this ->rejectCommand (
22662268 $ instance ,
22672269 $ run ,
22682270 CommandType::Signal,
22692271 'unknown_signal ' ,
22702272 $ this ->commandTargetScope (),
2271- $ this ->signalCommandPayloadAttributes ($ name , $ arguments , [], $ payloadCodec ),
2273+ $ this ->signalCommandPayloadAttributes (
2274+ $ name ,
2275+ $ arguments ,
2276+ [],
2277+ $ payloadCodec ,
2278+ $ signalAdmission ['payload ' ] ?? [],
2279+ ),
22722280 );
22732281 $ this ->recordRejectedSignal ($ command , $ name , $ arguments );
22742282
@@ -2462,14 +2470,19 @@ private function attemptSignalWithStartInternal(
24622470
24632471 $ this ->loadLockedRunRelations ($ run , $ instance );
24642472
2465- if (! RunCommandContract::hasSignal ($ run , $ name )) {
2473+ $ signalAdmission = $ this ->signalAdmissionForRun ($ run , $ name );
2474+
2475+ if (($ signalAdmission ['allowed ' ] ?? false ) !== true ) {
24662476 $ signalCommand = $ this ->rejectSignalCommandForContext (
24672477 $ commandContext ,
24682478 $ instance ,
24692479 $ run ,
24702480 $ name ,
24712481 $ signalArguments ,
24722482 'unknown_signal ' ,
2483+ [],
2484+ $ signalAdmission ['message ' ] ?? null ,
2485+ $ signalAdmission ['payload ' ] ?? [],
24732486 );
24742487
24752488 return ;
@@ -3381,6 +3394,7 @@ private function rejectSignalCommandForContext(
33813394 string $ reason ,
33823395 array $ validationErrors = [],
33833396 ?string $ message = null ,
3397+ array $ extraPayload = [],
33843398 ): WorkflowCommand {
33853399 /** @var WorkflowCommand $command */
33863400 $ command = WorkflowCommand::record ($ instance , $ run , $ this ->commandAttributesForContext (
@@ -3398,12 +3412,15 @@ private function rejectSignalCommandForContext(
33983412 $ arguments ,
33993413 $ validationErrors ,
34003414 null ,
3401- $ message === null
3402- ? []
3403- : [
3404- 'reason ' => $ reason ,
3405- 'message ' => $ message ,
3406- ],
3415+ array_merge (
3416+ $ extraPayload ,
3417+ $ message === null
3418+ ? []
3419+ : [
3420+ 'reason ' => $ reason ,
3421+ 'message ' => $ message ,
3422+ ],
3423+ ),
34073424 ),
34083425 ],
34093426 ));
@@ -3619,6 +3636,140 @@ private function validatedSignalArgumentsForRun(WorkflowRun $run, string $signal
36193636 : $ this ->normalizeNamedCommandArguments ($ contract , $ arguments );
36203637 }
36213638
3639+ /**
3640+ * @return array{
3641+ * allowed: bool,
3642+ * payload?: array<string, mixed>,
3643+ * message?: string
3644+ * }
3645+ */
3646+ private function signalAdmissionForRun (WorkflowRun $ run , string $ signalName ): array
3647+ {
3648+ $ contract = RunCommandContract::forRun ($ run );
3649+ $ diagnostics = $ this ->signalContractDiagnostics ($ contract );
3650+
3651+ if (in_array ($ signalName , $ contract ['signals ' ], true )) {
3652+ return [
3653+ 'allowed ' => true ,
3654+ 'payload ' => [
3655+ ...$ diagnostics ,
3656+ 'signal_admission ' => 'declared_signal ' ,
3657+ ],
3658+ ];
3659+ }
3660+
3661+ if (($ contract ['source ' ] ?? null ) !== RunCommandContract::SOURCE_DURABLE_HISTORY ) {
3662+ try {
3663+ $ workflowClass = TypeRegistry::resolveWorkflowClass ($ run ->workflow_class , $ run ->workflow_type );
3664+ } catch (LogicException ) {
3665+ return [
3666+ 'allowed ' => true ,
3667+ 'payload ' => [
3668+ ...$ diagnostics ,
3669+ 'signal_admission ' => 'external_contract_unavailable ' ,
3670+ ],
3671+ ];
3672+ }
3673+
3674+ if (WorkflowDefinition::hasSignal ($ workflowClass , $ signalName )) {
3675+ return [
3676+ 'allowed ' => true ,
3677+ 'payload ' => [
3678+ ...$ diagnostics ,
3679+ 'signal_admission ' => 'loadable_workflow_class ' ,
3680+ ],
3681+ ];
3682+ }
3683+
3684+ $ diagnostics = [
3685+ ...$ diagnostics ,
3686+ 'signal_admission ' => 'handler_not_declared_in_loadable_class ' ,
3687+ ];
3688+ $ message = $ this ->unknownSignalMessage ($ run , $ signalName , $ diagnostics );
3689+
3690+ return [
3691+ 'allowed ' => false ,
3692+ 'payload ' => [
3693+ ...$ diagnostics ,
3694+ 'reason ' => 'unknown_signal ' ,
3695+ 'message ' => $ message ,
3696+ ],
3697+ 'message ' => $ message ,
3698+ ];
3699+ }
3700+
3701+ $ diagnostics = [
3702+ ...$ diagnostics ,
3703+ 'signal_admission ' => 'handler_not_declared ' ,
3704+ ];
3705+ $ message = $ this ->unknownSignalMessage ($ run , $ signalName , $ diagnostics );
3706+
3707+ return [
3708+ 'allowed ' => false ,
3709+ 'payload ' => [
3710+ ...$ diagnostics ,
3711+ 'reason ' => 'unknown_signal ' ,
3712+ 'message ' => $ message ,
3713+ ],
3714+ 'message ' => $ message ,
3715+ ];
3716+ }
3717+
3718+ /**
3719+ * @param array<string, mixed> $contract
3720+ * @return array{
3721+ * command_contract_source: string|null,
3722+ * command_contract_backfill_needed: bool,
3723+ * command_contract_backfill_available: bool,
3724+ * declared_signals: list<string>
3725+ * }
3726+ */
3727+ private function signalContractDiagnostics (array $ contract ): array
3728+ {
3729+ $ signals = $ contract ['signals ' ] ?? [];
3730+
3731+ return [
3732+ 'command_contract_source ' => is_string ($ contract ['source ' ] ?? null )
3733+ ? $ contract ['source ' ]
3734+ : null ,
3735+ 'command_contract_backfill_needed ' => ($ contract ['backfill_needed ' ] ?? false ) === true ,
3736+ 'command_contract_backfill_available ' => ($ contract ['backfill_available ' ] ?? false ) === true ,
3737+ 'declared_signals ' => is_array ($ signals )
3738+ ? array_values (array_filter ($ signals , static fn (mixed $ signal ): bool => is_string ($ signal ) && $ signal !== '' ))
3739+ : [],
3740+ ];
3741+ }
3742+
3743+ /**
3744+ * @param array<string, mixed> $diagnostics
3745+ */
3746+ private function unknownSignalMessage (WorkflowRun $ run , string $ signalName , array $ diagnostics ): string
3747+ {
3748+ $ declaredSignals = $ diagnostics ['declared_signals ' ] ?? [];
3749+ $ declaredSummary = is_array ($ declaredSignals ) && $ declaredSignals !== []
3750+ ? implode (', ' , $ declaredSignals )
3751+ : 'none ' ;
3752+ $ contractSource = is_string ($ diagnostics ['command_contract_source ' ] ?? null )
3753+ ? $ diagnostics ['command_contract_source ' ]
3754+ : RunCommandContract::SOURCE_UNAVAILABLE ;
3755+ $ admission = is_string ($ diagnostics ['signal_admission ' ] ?? null )
3756+ ? $ diagnostics ['signal_admission ' ]
3757+ : 'handler_not_declared ' ;
3758+
3759+ $ detail = $ admission === 'handler_not_declared '
3760+ ? 'the durable command contract does not declare that handler '
3761+ : 'the server did not receive durable signal declarations and the loadable workflow class does not declare that handler ' ;
3762+
3763+ return sprintf (
3764+ 'Workflow signal [%s] is unknown for workflow type [%s]: %s. Command contract source [%s], declared signals [%s]. ' ,
3765+ $ signalName ,
3766+ $ run ->workflow_type ,
3767+ $ detail ,
3768+ $ contractSource ,
3769+ $ declaredSummary ,
3770+ );
3771+ }
3772+
36223773 /**
36233774 * @param array<int|string, mixed> $arguments
36243775 * @return array{arguments: list<mixed>, validation_errors: array<string, list<string>>}
0 commit comments