Skip to content

fix(stubs): named arguments no longer go missing in silence - #268

Open
gplanchat wants to merge 2 commits into
mainfrom
fix/stubs-arguments-nommes
Open

fix(stubs): named arguments no longer go missing in silence#268
gplanchat wants to merge 2 commits into
mainfrom
fix/stubs-arguments-nommes

Conversation

@gplanchat

@gplanchat gplanchat commented Sep 3, 2026

Copy link
Copy Markdown
Owner

The defect

The three __call stubs — ActivityStub, NexusStub, ChildWorkflowStub — turn the arguments they receive into a named payload. All three did it identically, and with the same defect:

$payload[$param->getName()] = $arguments[$i] ?? ($param->isDefaultValueAvailable() ? $param->getDefaultValue() : null);

PHP hands named arguments to __call in a string-keyed array. Matched by index, they answer to no $arguments[$i] — and every parameter falls back to its default value. No exception, no warning, no trace.

Here is what surfaced it: a child workflow started by

$env->childWorkflowStub(DurableAgentWorkflow::class)->run(prompt: $mission, model: 'ministral-3b-latest', maxTurns: 1)

started with prompt: null, maxTurns: 20 and the default model — then waited for a message that would never come. Diagnosed by reading the WORKFLOW_EXECUTION_STARTED payload in the Temporal history; nothing on the PHP side would have said so.

What is fixed

A single point, StubArguments::toPayload(), called by the three stubs — they were already doing the same thing, with the same mistake, and two of them would have diverged at the first partial fix.

Three behaviours, two of which were silent:

before after
named argument lost, default value matched to its parameter
explicit null default value — the opposite of what was asked null
unknown name (typo) swallowed, indistinguishable from an intended default BadMethodCallException

The second comes from ??, which conflates "absent" and "null"; hence array_key_exists. The third aligns with what PHP does with an ordinary call (Unknown named parameter).

Compatibility

No call in the repository used named arguments on a stub — verified by sweep — which explains how the defect survived. Positional calls behave exactly as before, with one exception: a null passed explicitly by position now means null and not the default. That is the fix, not a regression.

Verification

Six tests on StubArguments, one per way of getting it wrong, plus the positional reference case.

Found while writing agent delegation on spike/agent-durable-symfony-ai, which keeps a commented-out positional call in the meantime.


Addendum — 2026-09-04, after cross-review

No axis blocked this branch, and eight made the same remark: the principle is right and it was only half applied. The typo threw; the two other faults of the same kind stayed silent.

  • A required parameter that was not supplied became null — silently, including for a non-nullable type. The payload therefore travelled into the journal, and the refusal arrived one replay pass later, in a worker, far from the offending call. That is exactly the failure mode this branch was fixing for named arguments: a fault that travels. PHP throws ArgumentCountError on the corresponding ordinary call.
  • A parameter supplied both positionally and by name: the positional one won, without a word. PHP refuses ("Named parameter $x overwrites previous argument"), because there is no right answer to give.

Both now throw BadMethodCallException, like the unknown named argument. The type differs from PHP's — \Error, \ArgumentCountError — because the call goes through __call: that is the exception the SPL reserves for a method called the wrong way, and it stays catchable. The class docblock said "PHP does the same"; it now says what PHP actually does, and why the chosen type departs from it.

A variadic parameter is skipped: it has neither a default nor an obligation, so it cannot be missing, and it has no place of its own in a named payload.

testAMissingRequiredParameterIsNull codified the defect — "that is the previous behaviour, and validation stays with the handler". It is replaced by the two cases that throw, plus the neighbouring case that must keep passing: an optional parameter keeps its default, an explicit null stays null.

UPGRADE.md gains the missing note: three observable behaviours change across three published classes. It also says these exceptions are deterministic — replayed identically on every redelivery, they burn through Messenger's retries up to a failure transport.

The formatter has been run — that is what made CS red on all four PHP versions.

gplanchat and others added 2 commits September 3, 2026 19:18
Les trois stubs `__call` — activité, opération Nexus, workflow enfant —
transforment les arguments reçus en charge nommée, parce que c'est nommé que ça
voyage dans le journal. Tous les trois le faisaient à l'identique :

    $payload[$param->getName()] = $arguments[$i] ?? (… défaut …);

**PHP passe les arguments nommés à `__call` dans un tableau à clés de chaînes.**
Appariés par indice, ils ne répondent à aucun `$arguments[$i]` — et *tous* les
paramètres retombent sur leur valeur par défaut. Sans exception, sans
avertissement, sans trace.

C'est ce qui l'a fait sortir : un workflow enfant démarré par
`->run(prompt: $mission, model: 'ministral-3b-latest', maxTurns: 1)` démarrait
avec `prompt: null`, `maxTurns: 20` et le modèle par défaut, puis attendait un
message qui ne viendrait jamais. Diagnostiqué en lisant la charge de
`WORKFLOW_EXECUTION_STARTED` dans l'historique Temporal ; rien côté PHP ne
l'aurait dit.

Trois comportements, dont deux étaient silencieux :

| | avant | après |
|---|---|---|
| argument nommé | perdu, valeur par défaut | apparié à son paramètre |
| `null` explicite | valeur par défaut — l'inverse de ce qui est demandé | `null` |
| nom inconnu | avalé, indiscernable d'un défaut voulu | `BadMethodCallException` |

Le deuxième vient de `??`, qui confond « absent » et « null » ; d'où
`array_key_exists`. Le troisième s'aligne sur ce que PHP fait d'un appel
ordinaire (`Unknown named parameter`).

Un point unique, `StubArguments::toPayload()`, appelé par les trois : ils
faisaient déjà la même chose avec la même erreur, et un correctif partiel les
aurait fait diverger.

Aucun appel du dépôt n'utilisait d'arguments nommés sur un stub — balayage fait,
ce qui explique la longévité du défaut. Les appels positionnels sont inchangés,
à une exception près : un `null` passé explicitement vaut désormais `null` et non
le défaut. C'est le correctif, pas une régression.

563 tests d'activité, Nexus, workflow enfant et stubs au vert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… trois refus

La relecture croisée n'a bloqué cette branche sur aucun axe, et a fait la même remarque huit fois :
le principe est juste et il n'était appliqué qu'à moitié. La faute de frappe levait, les deux
autres fautes du même ordre se taisaient.

**Un paramètre requis non fourni valait `null`.** Silencieusement, y compris pour un type non
nullable — donc la charge partait dans le journal, et le refus arrivait une passe de rejeu plus
tard, dans un worker, loin de l'appel fautif. C'est exactement le mode de panne que cette branche
corrigeait pour les arguments nommés : une faute qui voyage. PHP lève `ArgumentCountError` sur
l'appel ordinaire correspondant.

**Un paramètre servi en positionnel *et* en nommé** : le positionnel gagnait, sans un mot. PHP
refuse (« Named parameter $x overwrites previous argument »), parce qu'il n'y a pas de bonne
réponse à donner.

Les deux lèvent désormais `\BadMethodCallException`, comme l'argument nommé inconnu. Le type diffère
de celui de PHP — `\Error`, `\ArgumentCountError` — parce que l'appel passe par `__call` : c'est
l'exception que la SPL réserve à une méthode appelée de travers, et elle reste rattrapable. Le
docbloc de classe disait « PHP fait de même » ; il dit maintenant ce que PHP fait vraiment, et
pourquoi le type retenu s'en écarte.

Un paramètre variadique est passé : il n'a ni valeur par défaut ni obligation, il ne peut donc pas
manquer, et il n'a pas de place à lui dans une charge nommée.

`testAMissingRequiredParameterIsNull` codifiait le défaut — « c'est le comportement d'avant, et la
validation reste au gestionnaire ». Il est remplacé par les deux cas qui lèvent, plus le cas voisin
qui doit continuer de passer : un paramètre optionnel garde son défaut, un `null` explicite reste
`null`.

`UPGRADE.md` gagne la note qui manquait : trois comportements observables changent sur trois
classes publiées. Elle dit aussi que ces exceptions sont déterministes — rejouées à l'identique à
chaque redélivrance, elles brûlent les tentatives de Messenger jusqu'à un transport d'échec.

Le formateur est repassé — c'est ce qui rendait la CS rouge sur les quatre versions de PHP.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@gplanchat gplanchat changed the title fix(stubs): les arguments nommés ne se perdent plus en silence fix(stubs): named arguments no longer go missing in silence Sep 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant