Skip to content

B1 — a recorded side effect is a state, not a return value - #273

Open
gplanchat wants to merge 2 commits into
mainfrom
fix/effet-de-bord-presence-du-slot
Open

B1 — a recorded side effect is a state, not a return value#273
gplanchat wants to merge 2 commits into
mainfrom
fix/effet-de-bord-presence-du-slot

Conversation

@gplanchat

@gplanchat gplanchat commented Sep 3, 2026

Copy link
Copy Markdown
Owner

First audit finding addressed. hasSideEffectForSlot(): bool joins WorkflowHistorySourceInterface.

The defect

findSideEffectForSlot() returns mixed and signalled "nothing recorded" with null. A closure that legitimately returns null was therefore indistinguishable from an empty slot:

$replayResult = $this->historySource->findSideEffectForSlot($slotIndex);
if (null !== $replayResult) { /* replay */ }
$result = $closure();                                    // ← re-run on every pass
$this->commandBuffer->recordSideEffect($this->uuid(), $result);   // ← one more event per pass

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, '', []. Only null failed: 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 ?? or isset() would reopen the hole for all five. That is also what UPGRADE.md tells backend authors — array_key_exists(), never isset().

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, an array_key_exists() for TemporalExecutionHistory. 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.md says 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

main this branch
unit suite 1073 tests, 4 errors 1089 tests, 4 errors
PHPStan 2 errors 2 errors

The 4 errors and the 2 diagnostics are pre-existing and identical: illuminate/cache is not installed on this machine, CI installs it through its matrix. No regression, no new diagnostic.

RED first: the three null cases 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.

  • The original text justified the shape by an existing separation for timers, {@see isTimerSettled()}. That method does not exist on WorkflowHistorySourceInterface. The real precedent is findScheduledTimerId() for state and findTimerSlotResult() for value — and the three sibling methods for activities, child workflows and Nexus operations, which wrap their result in array{result: mixed, ...} for exactly this reason. The docblock now says that.
  • The fix closed the hole for sideEffect() and left it open in hasRecordedWorkAhead(), the "am I replaying?" signal version() 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.

gplanchat and others added 2 commits September 3, 2026 23:28
…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>
@gplanchat gplanchat changed the title B1 — un effet de bord enregistré est un état, pas une valeur de retour B1 — a recorded side effect is a state, not a return value 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