B1 — a recorded side effect is a state, not a return value - #273
Open
gplanchat wants to merge 2 commits into
Open
B1 — a recorded side effect is a state, not a return value#273gplanchat wants to merge 2 commits into
gplanchat wants to merge 2 commits into
Conversation
…e retour `findSideEffectForSlot()` rend `mixed` et signalait « rien d'enregistré » par `null`. Une closure `sideEffect()` qui rend légitimement `null` était donc indistinguable d'un slot vide : elle était ré-exécutée à chaque passe de rejeu, et le journal grossissait d'un `SideEffectRecorded` par passe — c'est-à-dire exactement la garantie que la primitive existe pour offrir. `hasSideEffectForSlot(): bool` rejoint le port. La présence d'un slot ne se déduit plus de la valeur qu'il porte. La séparation existait déjà sur ce même port pour les minuteurs, où `isTimerSettled()` répond l'état et `findTimerSlotResult()` la valeur ; les effets de bord s'alignent dessus. Le test a délimité le défaut plus finement que le rapport d'audit : sur les cinq valeurs qui se confondent avec l'absence — null, false, 0, chaîne vide, liste vide — seule `null` échouait, la comparaison étant un `!==` strict. Les quatre autres restent dans le jeu de données comme filet : un correctif écrit avec `??` ou `isset()` rouvrirait le trou pour toutes. Les slots restaient alignés malgré le défaut, les réémissions atterrissant en fin de flux ; un cas le fixe pour que ça ne dépende plus d'un heureux hasard. Rupture pour qui implémente le port, c'est-à-dire pour qui écrit un backend ; aucune application appelante n'est touchée. Rector ne peut rien : la réponse dépend de la façon dont l'adaptateur range ses slots, et lui en faire deviner une produirait un adaptateur qui compile et ment. UPGRADE.md donne les deux formes attendues, et dit pourquoi `array_key_exists()` et jamais `isset()`. Suite unit : 1089 tests contre 1073 sur main, mêmes 4 erreurs d'environnement (illuminate/cache absent du poste, la CI l'installe par sa matrice). PHPStan : mêmes 2 erreurs pré-existantes, aucune nouvelle. Refs: B1 de documentation/audit/ Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…deux docblocs qui mentaient
La relecture croisée n'a bloqué cette branche sur aucun axe — douze l'ont retouchée, toutes sur la
même remarque : le correctif est juste et il s'est arrêté au symptôme cité.
**`hasRecordedWorkAhead()` gardait exactement le trou que la branche venait d'outiller.** C'est le
signal « en train de rejouer » dont `version()` dépend. Il interrogeait les quatre types de slot
qui savent dire leur présence et laissait les effets de bord de côté — et son docbloc l'assumait
en écrivant que `findSideEffectForSlot()` rend `mixed`, donc qu'on ne peut pas distinguer « rien
ici » de « ici, la valeur null ». Quinze lignes plus loin, la même branche ajoutait
`hasSideEffectForSlot()` au port, qui répond précisément à cette question.
Conséquence, et elle est pire que le trou d'origine : une exécution dont le travail restant devant
elle n'est fait que d'effets de bord était vue comme arrivée au bout de son historique. Elle
prenait la branche **neuve** en plein rejeu et y écrivait son marqueur de version, dans une
histoire écrite avant que le point de changement existe. `testUnTravailRestantFaitDEffetsDeBord-
RetientLAncienneVersion` rejoue le cas : sans ce commit, la seconde passe rend `3` là où elle doit
rendre `ChangePoint::DEFAULT_VERSION`.
**Le docbloc du port invoquait un précédent qui n'existe pas.** `{@see isTimerSettled()}` ne figure
pas parmi les dix-huit méthodes de `WorkflowHistorySourceInterface`. La séparation dont il voulait
se réclamer est réelle mais porte d'autres noms — `findScheduledTimerId()` pour l'état,
`findTimerSlotResult()` pour la valeur — et elle vaut aussi pour les activités, workflows enfants
et opérations Nexus, dont les trois méthodes sœurs enveloppent leur résultat dans un
`array{result: mixed, ...}` pour cette raison exacte. C'est cet argument-là qui tient.
`UPGRADE.md` gagne la note correspondante : le changement de `version()` ne demande rien à écrire,
mais il change un comportement observable et une application a le droit de savoir lequel.
Le formateur est repassé — c'est ce qui rendait la CS rouge sur les quatre versions de PHP.
Restent ouverts, hors périmètre : il n'existe aucune suite de conformance que les implémenteurs de
`WorkflowHistorySourceInterface` doivent passer, donc rien ne verra le prochain ajout au port ;
et la moitié Temporal du correctif reste vérifiée par la seule lecture.
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.
First audit finding addressed.
hasSideEffectForSlot(): booljoinsWorkflowHistorySourceInterface.The defect
findSideEffectForSlot()returnsmixedand signalled "nothing recorded" withnull. A closure that legitimately returnsnullwas therefore indistinguishable from an empty slot:A non-deterministic side effect replayed, a value that may differ from one pass to the next, and a journal that grows without bound — which is precisely the guarantee
sideEffect()exists to give.What the test taught us, and the report did not say
The data set covers the five values that look like absence —
null,false,0,'',[]. Onlynullfailed: the comparison was a strict!==, so the other four already replayed correctly. The defect was narrower than reported.The other four stay in the data set as a regression net: a fix written with
??orisset()would reopen the hole for all five. That is also whatUPGRADE.mdtells backend authors —array_key_exists(), neverisset().A fifth case pins slot alignment. It already passed — re-emissions landed at the end of the stream, so the indexing of the other slots stayed correct — but that was luck, and luck should not be load-bearing.
The fix
Slot presence is no longer inferred from the value the slot holds. Two implementations, two shapes: a journal walk for
EventStoreHistorySource, anarray_key_exists()forTemporalExecutionHistory. These are the only two implementations of the port in the repository — verified.Break
For anyone who implements the port, that is, anyone writing a backend. No calling application is affected: it gets the fix for free.
Rector cannot help here, and
UPGRADE.mdsays so rather than glossing over it: the answer depends on how the adapter stores its slots, and making Rector guess one would produce an adapter that compiles and lies. The note gives both reference shapes.Verification
mainunitsuiteThe 4 errors and the 2 diagnostics are pre-existing and identical:
illuminate/cacheis not installed on this machine, CI installs it through its matrix. No regression, no new diagnostic.RED first: the three
nullcases failed before the fix (Failed asserting that 2 is identical to 1,3 is identical to 1,3 is identical to 2).Addendum — 2026-09-04, after cross-review
Two claims above did not survive verification, and the branch has changed accordingly.
{@see isTimerSettled()}. That method does not exist onWorkflowHistorySourceInterface. The real precedent isfindScheduledTimerId()for state andfindTimerSlotResult()for value — and the three sibling methods for activities, child workflows and Nexus operations, which wrap their result inarray{result: mixed, ...}for exactly this reason. The docblock now says that.sideEffect()and left it open inhasRecordedWorkAhead(), the "am I replaying?" signalversion()depends on. An execution whose remaining work was made only of side effects was seen as having reached the end of its history: it took the new branch mid-replay and wrote its version marker into a history written before the change point existed. That is now closed too, with a regression test.