fix(stubs): named arguments no longer go missing in silence - #268
Open
gplanchat wants to merge 2 commits into
Open
fix(stubs): named arguments no longer go missing in silence#268gplanchat wants to merge 2 commits into
gplanchat wants to merge 2 commits into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The defect
The three
__callstubs —ActivityStub,NexusStub,ChildWorkflowStub— turn the arguments they receive into a named payload. All three did it identically, and with the same defect:PHP hands named arguments to
__callin 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
started with
prompt: null,maxTurns: 20and the default model — then waited for a message that would never come. Diagnosed by reading theWORKFLOW_EXECUTION_STARTEDpayload 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:
nullnullBadMethodCallExceptionThe second comes from
??, which conflates "absent" and "null"; hencearray_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
nullpassed explicitly by position now meansnulland 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.
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 throwsArgumentCountErroron the corresponding ordinary call.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.
testAMissingRequiredParameterIsNullcodified 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 explicitnullstaysnull.UPGRADE.mdgains 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.