Skip to content

fix(core): the divergence guard compares the payload too, on all three slot kinds - #282

Merged
gplanchat merged 3 commits into
mainfrom
fix/dur042-compare-aussi-la-charge
Sep 7, 2026
Merged

fix(core): the divergence guard compares the payload too, on all three slot kinds#282
gplanchat merged 3 commits into
mainfrom
fix/dur042-compare-aussi-la-charge

Conversation

@gplanchat

@gplanchat gplanchat commented Sep 4, 2026

Copy link
Copy Markdown
Owner

Le trou

DUR042 ne comparait que le nom de l'activité au slot :

$this->refuseDivergence('activity', $slotIndex, $this->historySource->activityNameForSlot($slotIndex), $requested);

La charge ne traversait jamais la comparaison. Un replay qui recalcule un payload différent voit le journal servir l'ancien résultat, la charge fraîche partir à la poubelle, et l'exécution se terminer en succès en ayant menti sur ce qu'elle avait demandé.

Mesuré, pas supposé

Mutation injectée dans le client de modèle de la maquette d'agent — une charge qui diverge entre deux passes de rejeu du même run, identique d'un run à l'autre :

avant après
sans mutation vert vert (aucun faux positif)
uniqid() dans la charge rouge rouge
charge divergente au rejeu vert ← le trou rouge

Sonde posée sur les charges calculées dans DurableModelClient::request(), avant correctif : 12 charges calculées pour 3 journalisées1+1+2+2+3+3 sur les six passes de rejeu. Sans mutation, ces douze empreintes se réduisent à trois valeurs distinctes ; sous mutation, elles sont douze, donc neuf divergences avalées sans un mot.

Le correctif

activityPayloadForSlot(): ?array rejoint WorkflowHistorySourceInterface, implémenté par les deux backends. Aucun champ ajouté aux événements : ActivityScheduled porte déjà la charge, et l'entrée Temporal aussi — les histoires anciennes restent donc lisibles, ce que la garde exige d'elle-même depuis DUR042.

L'empreinte canonique est calculée des deux côtés au même endroit, dans ExecutionContext. C'est le point important : le côté enregistré a fait l'aller-retour JSON du magasin, le côté frais non. Deux canonicaliseurs séparés auraient dérivé, et la dérive se serait vue en production sous forme de faux positifs irreproductibles d'un backend à l'autre.

Ce que la garde ne voit pas, volontairement

Un faux positif arrête une exécution saine ; il coûte plus cher que le trou qu'il bouche. Donc :

  • un objet dont le journal ne retient rien — DTO à propriétés privées, le style de la maison — rend la même empreinte des deux côtés. Vérifié avant d'être écrit : sans cette normalisation, {} à l'aller contre [] au retour faisait diverger à chaque reprise toute exécution portant un DTO ;
  • l'ordre des clés d'un objet JSON n'est pas de l'information ; l'ordre d'une liste, lui, en est, et une liste réordonnée diverge ;
  • une charge inencodable (ressource, NAN) désarme la garde plutôt que d'accuser ce qu'elle ne sait pas lire ;
  • une histoire écrite avant ce changement n'a rien à comparer et passe inchangée.

Le message

Sur une charge d'agent de 4 ko, deux préfixes tronqués se ressemblaient trait pour trait. Le message nomme donc l'octet de divergence et montre une fenêtre autour :

payload changed at byte 4072. History recorded …"model":"mistral-small-latest","nonce":1}},
code scheduled …"model":"mistral-small-latest","nonce":2}} (4075 and 4075 bytes)

Il dit aussi que la cause est du code non déterministe, pas un écart de version — l'ancien message renvoyait vers ChangePoint, qui n'y peut rien.

Vérification

  • 9 tests neufs dans tests/unit/Durable/Replay/ActivityPayloadDivergenceTest.php — les deux moitiés de la règle, ce qui doit être refusé et tout ce qui doit continuer de passer.
  • Suite complète : 13 erreurs avant, 13 après, toutes Illuminate\Cache\* absentes de ce vendor, présentes sur main. Zéro régression.
  • PHPStan : 2 erreurs, les mêmes que main. Psalm : 1, la même. cs:check : 0.

Les trois formes de fil

La garde relit trois entrées d'appel, et elles ne se valent pas — c'est là qu'une erreur de désenveloppage se cacherait :

slot forme sur le fil
activité Payloads d'un élément, portant l'enveloppe TemporalActivityScheduleInput
Nexus un Payload nu — l'enveloppe {operationId, payload} a été retirée du tampon (tâche 1.1)
enfant Payloads d'un élément, portant l'input nu

Les fixtures sont écrites d'après ce que TemporalWorkflowCommandBuffer produit, pas d'après le voisin : celle de NexusSlotDivergenceTest est antérieure au retrait de l'enveloppe et porte encore l'ancienne forme. Ne pas aligner les nouvelles dessus.

TemporalExecutionHistory::fromEvents() fabrique un historique synthétique sans serveur — ce qui lève la réserve que portait la première version de cette PR : le chemin Temporal des activités est désormais mesuré lui aussi, au même titre que Nexus et l'enfant.

Nexus n'est gardé que côté pont, et c'est structurel : le backend journal refuse ces opérations par construction (DUR036), et son NexusOperationScheduled ne porte que le site d'appel — jamais la charge. EventStoreHistorySource::nexusOperationPayloadForSlot() rend donc null, avec la raison écrite.

Une garde, trois slots

refusePayloadDivergence() est unique et prend son type de slot de l'appelant — exactement comme refuseDivergence() tient déjà l'identité des trois. Ce qui identifie un slot n'est pas de même nature partout : un nom pour une activité, un type pour un enfant, un triplet pour Nexus, et le message le dit dans les termes du slot concerné.

Aucun champ ajouté à aucun événement, sur les trois : les charges étaient déjà sur le fil.

Note de performance

activityPayloadForSlot() fait sa propre passe sur le flux, comme ses voisines. Le chemin activity() passe donc de trois à quatre balayages par appel. C'est le motif existant, pas un nouveau — mais il reste quadratique sur une exécution longue.

DUR042 ne comparait que le nom de l'activité au slot. La charge ne traversait
jamais la comparaison : un replay qui recalculait un payload différent voyait le
journal servir l'ancien résultat, la charge fraîche partir à la poubelle, et
l'exécution se terminer en succès en ayant menti sur ce qu'elle avait demandé.

Mesuré par mutation sur une maquette d'agent, avant correctif : neuf charges
calculées, trois journalisées, six divergences avalées sans un mot, suite verte.

`activityPayloadForSlot(): ?array` rejoint le port, et la comparaison passe par
une empreinte canonique calculée **des deux côtés au même endroit** — c'est ce
qui la rend symétrique entre un côté enregistré qui a fait l'aller-retour JSON
du magasin et un côté frais qui ne l'a pas fait.

Ce que la garde ne voit volontairement pas, parce qu'un faux positif arrête une
exécution saine et coûte plus cher que le trou qu'il bouche :
- un objet dont le journal ne retient rien — DTO à propriétés privées, le style
  de la maison — rend la même empreinte des deux côtés ;
- l'ordre des clés d'un objet JSON, qui n'est pas de l'information ; l'ordre
  d'une liste, lui, en est ;
- une charge inencodable (ressource, NAN) désarme la garde ;
- une histoire écrite avant ce changement n'a rien à comparer.

Le message nomme l'octet où les deux empreintes divergent et montre une fenêtre
autour : sur une charge d'agent de 4 ko, deux préfixes tronqués se ressemblaient
trait pour trait et n'apprenaient rien.

Hors périmètre : les slots Nexus et workflow enfant comparent toujours leur
seule identité. Le trou y est le même, et l'enjeu plus grand côté Nexus, où le
doublon part chez un tiers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@gplanchat
gplanchat force-pushed the fix/dur042-compare-aussi-la-charge branch from 9fa4ada to 823d40e Compare September 4, 2026 18:18
La garde de charge ne couvrait que les activités. Les deux autres types de slot
avaient le même trou : l'identité comparée, la charge jamais.

Une seule méthode pour les trois — `refusePayloadDivergence()` — comme
`refuseDivergence()` tient déjà l'identité des trois. Ce qui identifie un slot
n'étant pas de même nature partout, le type et l'identité viennent de
l'appelant : un nom pour une activité, un type pour un enfant, un triplet pour
Nexus.

Aucun champ ajouté à aucun événement : les trois charges étaient déjà sur le
fil. Le désenveloppage, lui, diffère aux trois endroits, et c'est là qu'une
erreur se serait cachée :
- activité — `Payloads` d'un élément portant l'enveloppe TemporalActivityScheduleInput ;
- Nexus — un `Payload` **nu**, l'enveloppe ayant été retirée du tampon (tâche 1.1) ;
- enfant — `Payloads` d'un élément portant l'input nu.

Les fixtures sont écrites d'après ce que le tampon produit, pas d'après le
voisin : celle de NexusSlotDivergenceTest est antérieure au retrait de
l'enveloppe et porte encore `{operationId, payload}`.

Nexus n'est gardé que côté pont Temporal, et c'est structurel : le backend
journal refuse ces opérations par construction (DUR036) et son événement ne
porte que le site d'appel.

`fromEvents()` permet de fabriquer un historique Temporal synthétique sans
serveur — ce qui lève au passage la réserve de la PR sur le chemin Temporal des
activités, désormais mesuré lui aussi.

Vérifié par mutation : neutraliser la garde partagée fait tomber exactement les
sept tests qui l'éprouvent, sur les trois types de slot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@gplanchat gplanchat changed the title fix(coeur): la garde de divergence compare aussi la charge de l'activité fix(coeur): la garde de divergence compare aussi la charge, sur les trois types de slot Sep 4, 2026
One conflict, in `ExecutionContext`. This branch adds the payload guard with French docblocks;
main meanwhile translated the file, and `docs/core-comments-in-english` left it with no accented
character in it.

Both kept: every line of the guard, and the file's English. Merging the branch as it stood would
have put French back into a file the sweep had already finished, and the sweep would have had to
come round again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@gplanchat gplanchat changed the title fix(coeur): la garde de divergence compare aussi la charge, sur les trois types de slot fix(core): the divergence guard compares the payload too, on all three slot kinds Sep 7, 2026
@gplanchat
gplanchat enabled auto-merge September 7, 2026 21:52
@gplanchat
gplanchat merged commit 01ecd81 into main Sep 7, 2026
34 checks passed
gplanchat added a commit that referenced this pull request Sep 7, 2026
One conflict in `RegisterDurableMiddlewarePass`, and the same shape as #282: this branch adds a
paragraph in French where main has since translated the file. Both kept, the new paragraph in
English.

The sweep had already reached `src/DurableBundle`, so three files came back with French in them:
eleven lines of comment, a `->info()` description and, worse, a `LogicException` message a user
reads when they name a bus that does not exist. All four are English now. WA006 landed while this
branch waited, and shipping a French exception from the bundle would have undone #287 the day after
it merged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
gplanchat added a commit that referenced this pull request Sep 7, 2026
#287, #292, #293, #294, #296, #299 and #301 are all merged. PRISES.md says removing the claim is
part of the merge, and I had left all seven standing — `bin/prises-check.sh` was red on them.

Five more are stale and belong to other sessions (#251, #268, #270, #282, #283); a wrong write to
this registry is what the registry exists to prevent, so they stay for their owners to remove.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
gplanchat added a commit that referenced this pull request Sep 7, 2026
#273, #274, #275, #276, #279 and #280 are in main, with #282 and #283. The two remaining audit
findings, #277 and #278, keep their claims: both fail QA on all four PHP versions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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