From 09043fb7c716ac7c0de817540fbe64551c3c1676 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Fri, 23 Oct 2026 11:11:55 +0200 Subject: [PATCH 1/4] =?UTF-8?q?Protocole=20optimiseur=201.3=20:=20forme=20?= =?UTF-8?q?norm=C3=A9e=20d'error.data,=20le=20token=20avant=20la=20majeure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contrat (docs/etm-powersync-optimizer-protocol.openrpc.json) : - composants InvalidContextData, ContextProblem, ContextProblemCode : error.data du -32602 de GetPlan = {"problems":[{path, problem}]}, 1 à 50 problèmes, truncated au-delà, chemin depuis params (motif), nature dans une liste fermée (missing, type, range, format, enum, constraint), formes fermées : aucune valeur reçue ne peut s'y glisser ; - x-etm-data-schema et x-etm-data-required-from (1.3) sur cette erreur ; - Handshake : token faux et autre majeure dans le même handshake → -32001, le token d'abord ; - tolérance : error.data jamais lu par le moteur. Prose (docs/OPTIMIZER_PROTOCOL.md) : v1.3, changelog §0 avec la compatibilité 1.2 ↔ 1.3, §2 (un lot reçu par le moteur reste sans réponse, à dessein), §3 (le token d'abord), §7 (forme d'error.data, et ce que le moteur en fait : rien), annexes A, D, E, G. Blocs générés régénérés. Générateur : un `|` dans une cellule (alternative d'un motif) est échappé ; une forme de l'optimiseur fermée à dessein dit pourquoi (x-etm-closed-why) au lieu de « émise par le moteur ». Test ajouté, vérifié échouant avec l'ancien générateur. Moteur : version annoncée au handshake 1.3 (OptimizerProtocol::ProtocolVersion, OptimizerClient::kProtocolVersion). Rien d'autre ne change dans le client. Tests qui citent la version mis à jour (optimizerclient, socketscheduler, test du validateur). Co-Authored-By: Claude Opus 5.5 --- docs/OPTIMIZER_PROTOCOL.md | 155 +++++++++++++++--- ...-powersync-optimizer-protocol.openrpc.json | 87 ++++++++-- energyplugin/etm/scheduler/optimizerclient.h | 2 +- .../etm/scheduler/optimizerprotocol.h | 2 +- .../optimizerclient/testoptimizerclient.cpp | 6 +- .../socketscheduler/testsocketscheduler.cpp | 2 +- .../etm-powersync-gen-protocol-doc-test.py | 13 ++ .../tools/etm-powersync-schema-check-test.py | 5 +- tools/etm-powersync-gen-protocol-doc | 17 +- 9 files changed, 244 insertions(+), 45 deletions(-) diff --git a/docs/OPTIMIZER_PROTOCOL.md b/docs/OPTIMIZER_PROTOCOL.md index 3969935..88e97ae 100644 --- a/docs/OPTIMIZER_PROTOCOL.md +++ b/docs/OPTIMIZER_PROTOCOL.md @@ -1,4 +1,4 @@ -# OPTIMIZER_PROTOCOL — v1.2 +# OPTIMIZER_PROTOCOL — v1.3 Contrat entre le moteur d'énergie **etm-powersync-energy-plugin-etm** (couche A, GPL-3.0) et un **optimiseur** externe (couche B : l'optimiseur ETM, ou toute implémentation tierce). @@ -8,7 +8,7 @@ Ce document fait autorité. Il sert trois usages : 2. Spécification du protocole socket pour un optimiseur en process séparé. 3. Documentation publique : n'importe qui peut implémenter son propre optimiseur. -Statut : **v1.2** — figé à la fin de la beta. Versionné en semver (`protocolVersion`). +Statut : **v1.3** — figé à la fin de la beta. Versionné en semver (`protocolVersion`). > ### La forme est un contrat MACHINE > @@ -42,6 +42,34 @@ Statut : **v1.2** — figé à la fin de la beta. Versionné en semver (`protoco ## 0. Changelog +### 1.3 (2026-10) +Mineure. Norme la **forme d'`error.data`** de l'erreur `-32602` à un `GetPlan` dont le contexte est +invalide, et écrit deux règles que la 1.2 laissait à déduire. **Côté moteur, seule la version +annoncée change** : il n'émet aucun message nouveau, juge les réponses exactement comme en 1.2, et +ne lit pas `error.data` (§7). + +**Compatibilité.** Même majeure : un moteur 1.3 accepte un optimiseur 1.0 à 1.2, et un optimiseur +1.3 accepte un moteur 1.0 à 1.2 (seule la majeure est comparée, §3). Les deux règles nouvelles +portent sur des situations que le moteur ne produit pas — un contexte invalide, un handshake d'une +autre majeure. Un optimiseur 1.2 qui renvoie un autre `data`, ou qui juge la majeure avant le token, +**continue de fonctionner avec ce moteur** : le moteur ne lit pas `error.data`, et traite toute +réponse `error` au handshake comme un refus, quel que soit son code. Il n'est pas conforme 1.3 sur +ces points, et s'aligne avant de s'annoncer 1.3 — la suite de conformité l'exige d'un optimiseur qui +s'annonce 1.3 ou plus, et se contente d'un avertissement pour un optimiseur 1.2 (annexe G). + +- §7 **`error.data` d'un contexte invalide** : `{"problems":[{"path","problem"}]}` — liste non vide + d'au plus 50 problèmes (`truncated: true` à côté s'il y en avait davantage) ; `path` depuis + `params`, points et crochets ; `problem` dans une liste fermée (`missing`, `type`, `range`, + `format`, `enum`, `constraint`). Jamais la valeur reçue, dans aucun champ. Le moteur ignore un + code qu'il ne connaît pas (§9) et ne lit pas `error.data` pour décider. +- §3 **Token faux et autre majeure dans le même handshake** : le **token est jugé d'abord** — + `-32001`, puis fermeture. +- §2 **Un lot reçu par le moteur reste sans réponse** : c'est l'intention, désormais écrite (le + comportement est celui de 1.2). +- Contrat machine : composants `InvalidContextData`, `ContextProblem`, `ContextProblemCode` ; + `x-etm-data-schema` et `x-etm-data-required-from` sur l'erreur `-32602` de `GetPlan` ; ordre + token puis majeure dans les erreurs du `Handshake`. + ### 1.2 (2026-10) Mineure. Norme les points que le premier optimiseur réel a trouvés ouverts : erreurs renvoyées par l'optimiseur, fermeture après un refus, token non exigé, sens d'`UpdateLearned`, longueur de ligne, @@ -156,7 +184,10 @@ valeur avait été écrite sans définition ; **aucun émetteur ne l'a jamais pr (`[…]`) est interdit en émission. Reçu par l'optimiseur : erreur `-32600` à `id: null`, aucun message du lot n'est traité, connexion conservée. Reçu par le moteur : ligne écartée (`invalid-json` : ce n'est pas un objet), comptée, connexion conservée ; une réponse contenue - dans le lot n'est pas lue, et la requête qu'elle visait finit en délai dépassé (§4). + dans le lot n'est pas lue, et la requête qu'elle visait finit en délai dépassé (§4). **Le moteur + n'y répond pas, et c'est voulu** (écrit en 1.3) : client du protocole, il ne renvoie jamais de + réponse sur ce socket — une erreur à `id: null` n'aurait personne pour l'attendre. Le délai + dépassé de la requête visée est le seul effet visible, et il est compté (§7). - **Longueur de ligne : `maxLineBytes` = 1 Mio (1 048 576 octets, `\n` non compté), dans les deux sens** (1.2). Reçue trop longue par l'optimiseur : jetée sans être gardée en mémoire, erreur `-32600` à `id: null`, connexion conservée. Reçue par le moteur : ligne écartée @@ -176,7 +207,7 @@ valeur avait été écrite sans définition ; **aucun émetteur ne l'a jamais pr ```json {"jsonrpc":"2.0","id":1,"method":"Handshake","params":{ - "protocolVersion":"1.2", + "protocolVersion":"1.3", "engine":"etm-powersync-energy-plugin-etm/", "token":"" }} @@ -186,7 +217,7 @@ Réponse attendue : ```json {"jsonrpc":"2.0","id":1,"result":{ - "protocolVersion":"1.2", + "protocolVersion":"1.3", "name":"etm-optimizer", "planning":true, "capabilities":{ @@ -224,6 +255,11 @@ Réponse attendue : est fermé **des deux côtés** : l'optimiseur ferme après avoir envoyé l'erreur, le moteur à sa réception. Le moteur traite **toute** réponse `error` au handshake comme un refus, quel que soit son code (`handshake-refused`), et retente au backoff (§2). +- **Le token d'abord** (1.3). Un handshake qui porte à la fois un token faux (ou absent alors + qu'il est exigé) et une autre majeure reçoit **`-32001`**, puis la connexion est fermée : + l'optimiseur juge le token avant la version. Un pair qui ne présente pas le secret n'apprend + rien d'autre — pas même quelle majeure l'optimiseur parle. `-32602` ne répond donc qu'à un + handshake dont le token est accepté, ou qui n'en exige pas. - **`GetPlan` avant un handshake réussi** (1.2) : l'optimiseur répond `-32600` et **garde** la connexion — le client peut encore envoyer `Handshake`. Le moteur n'en envoie jamais : il n'appelle `GetPlan` qu'une fois le handshake accepté. @@ -236,7 +272,7 @@ Réponse attendue : | Champ | Type | Obligatoire | Valeurs | Sens | |---|---|---|---|---| -| `protocolVersion` | `ProtocolVersion` | oui | `"1.2"` | Version du moteur ; seule la majeure est comparée, des deux côtés (une autre majeure : l'optimiseur répond -32602 et ferme). | +| `protocolVersion` | `ProtocolVersion` | oui | `"1.3"` | Version du moteur ; seule la majeure est comparée, des deux côtés (une autre majeure : l'optimiseur répond -32602 et ferme — -32001 si le token est faux aussi, 1.3). | | `engine` | string | oui | motif `^etm-powersync-energy-plugin-etm/` | Moteur et sa version. | | `token` | string | non | ≥ 16 car. | Secret partagé (§3, E-T2-8) : 16 caractères au moins, 4 096 octets au plus (borne en octets non exprimable ici : contrôlée par le moteur), imprimable, sans blanc ni caractère de contrôle, UTF-8 admis. Envoyé seulement s'il est configuré ; jamais journalisé. | @@ -1227,15 +1263,69 @@ une abstention, à ce qu'il ne peut pas traiter. En particulier : protocole. Une situation que le tableau ne nomme pas prend le code standard qui lui correspond (`-32603` erreur interne, par exemple). +**Forme d'`error.data` pour un contexte invalide** (1.3). Exigée d'un optimiseur qui s'annonce 1.3 ou +plus : + +```json +{"jsonrpc":"2.0","id":42,"error":{"code":-32602,"message":"contexte invalide","data":{ + "problems":[{"path":"params.loads[0].id","problem":"missing"}, + {"path":"params.forecast.pv[3].from","problem":"format"}]}}} +``` + +- `problems` : liste **non vide**, de **50 entrées au plus**. S'il y a davantage de problèmes, les 50 + premiers, et **`"truncated": true`** à côté de `problems` — champ absent sinon. +- `path` : chemin du champ fautif **depuis `params`**, noms séparés par des points, indices de liste + entre crochets (`params.forecast.pv[3].from`, `params.loads[0].declared.estimatedPowerW.3`). Pour + un champ absent, le chemin où il était attendu. +- `problem` : un code d'une **liste fermée** — `missing` (obligatoire absent), `type` (type faux), + `range` (hors bornes), `format` (chaîne mal formée : instant, durée…), `enum` (valeur hors liste), + `constraint` (règle entre champs, ordre des créneaux…). +- **Jamais la valeur reçue**, dans aucun champ : aucun autre champ que ceux-là, un chemin qui suit son + motif, une nature prise dans la liste. La forme fermée rend la recopie inexprimable — c'est le but : + une valeur du site n'a rien à faire dans une erreur qu'un journal peut garder. +- **Ce que le moteur en fait : rien.** Il ne lit pas `error.data` pour décider — l'erreur reste un + échec `rpc-error`, règle des deux échecs inchangée —, ne le journalise pas (le code JSON-RPC seul), + et ne le recopie pas dans la trace (annexe D : la ligne porte `rpc-error`). Un code `problem` qu'il + ne connaît pas, ou un `data` d'une autre forme, n'y change donc rien (§9). La forme sert celui qui + lit l'échange : le validateur, la suite de conformité, l'installateur qui capture une session. + + + + +**`InvalidContextData`** — error.data de la réponse -32602 à GetPlan (§7, 1.3). Exigé d'un optimiseur qui s'annonce 1.3 ou plus ; un optimiseur 1.2 peut répondre un autre data, le moteur ne le lit pas. Aucune valeur reçue n'y figure, dans aucun champ : la forme est fermée, le chemin suit un motif, la nature est un code d'une liste fermée. + +| Champ | Type | Obligatoire | Valeurs | Sens | +|---|---|---|---|---| +| `problems` | [`ContextProblem`] | oui | | Les problèmes trouvés, de 1 à 50 ; s'il y en a davantage, les 50 premiers, et truncated. | +| `truncated` | boolean | non | `true` | Présent, et vrai, seulement si des problèmes ont été omis (problems en compte alors 50) ; absent sinon. | + +Champs non déclarés : **refusés** (forme de l'optimiseur fermée à dessein : aucune valeur reçue ne doit pouvoir s'y glisser). + + + + + + +**`ContextProblem`** — Un problème du contexte GetPlan (1.3) : où, et de quelle nature — jamais la valeur reçue. + +| Champ | Type | Obligatoire | Valeurs | Sens | +|---|---|---|---|---| +| `path` | string | oui | motif `^params(\.[A-Za-z0-9_]+\|\[[0-9]+\])*$` | Chemin du champ fautif depuis params : noms séparés par des points, indices de liste entre crochets (params.forecast.pv[3].from). Champ absent : son chemin attendu. | +| `problem` | string | oui | `missing` · `type` · `range` · `format` · `enum` · `constraint` | Nature d'un problème du contexte (1.3), liste fermée : missing (obligatoire absent), type (type faux), range (hors bornes), format (chaîne mal formée : instant, durée…), enum (valeur hors liste), constraint (règle entre champs, ordre des créneaux…). Un code inconnu est ignoré par le moteur (§9). | + +Champs non déclarés : **refusés** (forme de l'optimiseur fermée à dessein : aucune valeur reçue ne doit pouvoir s'y glisser). + + + | Code | Méthode | `message` | Connexion après l'erreur | Quand | |---|---|---|---|---| -| `-32001` | Handshake | token refusé | fermée | Token configuré côté optimiseur, et absent ou différent dans le Handshake. Un token envoyé alors que l'optimiseur n'en exige pas est ACCEPTÉ. | -| `-32602` | Handshake | majeure incompatible | fermée | Majeure de params.protocolVersion différente de celle de l'optimiseur (1.2). | +| `-32001` | Handshake | token refusé | fermée | Token configuré côté optimiseur, et absent ou différent dans le Handshake — jugé AVANT la majeure (1.3) : token faux et autre majeure dans le même Handshake → -32001. Un token envoyé alors que l'optimiseur n'en exige pas est ACCEPTÉ. | +| `-32602` | Handshake | majeure incompatible | fermée | Majeure de params.protocolVersion différente de celle de l'optimiseur (1.2), le token étant accepté ou non exigé (1.3 : le token est jugé d'abord). | | `-32600` | GetPlan | handshake requis | conservée | GetPlan reçu avant un Handshake réussi sur cette connexion (1.2). Le moteur n'en envoie jamais : il attend d'être prêt. | -| `-32602` | GetPlan | contexte invalide | conservée | Contexte GetPlan non conforme au contrat (1.2). error.data liste les problèmes : pour chacun, le chemin du champ fautif et la nature du problème, JAMAIS la valeur reçue. Une erreur et non une abstention : un contexte invalide est un défaut du moteur, une abstention le cacherait. | +| `-32602` | GetPlan | contexte invalide | conservée | Contexte GetPlan non conforme au contrat (1.2). error.data liste les problèmes : pour chacun, le chemin du champ fautif et la nature du problème, JAMAIS la valeur reçue. Forme normée en 1.3 : InvalidContextData. Une erreur et non une abstention : un contexte invalide est un défaut du moteur, une abstention le cacherait. | | `-32603` | GetPlan | erreur interne | conservée | Code JSON-RPC 2.0 standard pour toute autre panne de l'optimiseur. | | `-32700` | toute | erreur d'analyse | conservée | Ligne qui n'est pas du JSON (ou pas de l'UTF-8). Réponse à id null. Code standard JSON-RPC 2.0. | | `-32600` | toute | lot interdit | conservée | Ligne qui est un lot JSON-RPC ([…]) (1.2). Une seule réponse, à id null ; aucun message du lot n'est traité. | @@ -1247,7 +1337,7 @@ Ce que l'OPTIMISEUR répond à un message qu'il ne peut pas traiter, quelle que Ce que fait le moteur d'une réponse error : - **Handshake** : Toute réponse error au Handshake, quel que soit son code : handshake-refused, le moteur ferme, nouvelle tentative au backoff. -- **GetPlan** : Toute réponse error à GetPlan, quel que soit son code : échec de la requête (rpc-error), compté par la règle des échecs consécutifs (§7). +- **GetPlan** : Toute réponse error à GetPlan, quel que soit son code : échec de la requête (rpc-error), compté par la règle des échecs consécutifs (§7). error.data n'est PAS lu : ni décision, ni journal (le code seul), ni trace (le code de l'échec seul, rpc-error). @@ -1278,7 +1368,7 @@ Ce que le moteur refuse EN PLUS de la forme, dans l'ordre de ses contrôles (Opt | `expired` | GetPlan | expired | `expired` | non | request | non | validUntil ≤ timestamp de la requête. | | `no-current-slot` | GetPlan | abstention | `no-current-slot` | non | request | non | aucun créneau ne couvre le timestamp de la requête. | | `incomplete-slot` | GetPlan | invalid | `incomplete-slot` | oui | request | non | une charge available de la requête n'a pas d'action dans le créneau courant (décision Q1). | -| `handshake-refused` | Handshake | connection-refused | `no-plan` | non | — | non | réponse error, quel que soit son code (token refusé : -32001 ; majeure incompatible : -32602, Handshake.errors) → le moteur ferme, nouvelle tentative au backoff. | +| `handshake-refused` | Handshake | connection-refused | `no-plan` | non | — | non | réponse error, quel que soit son code (token refusé : -32001, jugé d'abord ; majeure incompatible : -32602, Handshake.errors) → le moteur ferme, nouvelle tentative au backoff. | | `invalid-handshake` | Handshake | connection-refused | `no-plan` | non | — | oui | result non objet, protocolVersion absent ou vide, capabilities présent et non objet. | | `major-mismatch` | Handshake | connection-refused | `no-plan` | non | — | non | MAJEURE de protocolVersion (ce qui précède le premier point) différente de celle du moteur. | @@ -1298,7 +1388,7 @@ qu'elle affiche de ces trois champs. - **Sens unique : de l'optimiseur vers le moteur** (1.2). Le moteur n'en envoie jamais. Reçue par un optimiseur : en notification (sans `id`), ignorée sans réponse ; en requête (avec `id`), erreur `-32601` (méthode inconnue). -- **La forme d'`UpdateLearned` n'est pas normée en 1.2.** Le tableau ci-dessous dit ce que le +- **La forme d'`UpdateLearned` n'est pas normée** (ni en 1.2, ni en 1.3). Le tableau ci-dessous dit ce que le contrat admet, pas ce qu'un moteur en fera : la forme sera fixée avec la première stratégie qui apprend. - Tant que `confidence < 0.7` (seuil indicatif), l'app peut afficher un badge @@ -1312,7 +1402,7 @@ qu'elle affiche de ces trois champs. | Champ | Type | Obligatoire | Valeurs | Sens | |---|---|---|---|---| -| `loads` | [object] | non | | Non normé en 1.2 : intention §8 (learned + confidence par charge). | +| `loads` | [object] | non | | Non normé (1.2, 1.3) : intention §8 (learned + confidence par charge). | Champs non déclarés : **refusés** (forme émise par le moteur, fermée). @@ -1326,7 +1416,7 @@ Champs non déclarés : **refusés** (forme émise par le moteur, fermée). majeure** et elle seule (§3) : le moteur refuse un optimiseur d'une autre majeure, et l'optimiseur un moteur d'une autre majeure (`-32602`, 1.2). Toute mineure est acceptée. - Une mineure n'ajoute que des champs optionnels, ou des règles sur des situations que l'autre - côté conforme ne produit pas (1.2 en est un exemple, §0). Elle se lit comme une liste de choses + côté conforme ne produit pas (1.2 et 1.3 en sont des exemples, §0). Elle se lit comme une liste de choses à aligner avant de s'annoncer de cette mineure, pas comme une rupture. - Champs inconnus dans `params`/`result` : **ignorés silencieusement**. - Changements incompatibles (renommage, sémantique) = majeure + entrée changelog §0. @@ -1336,13 +1426,13 @@ Champs non déclarés : **refusés** (forme émise par le moteur, fermée). ## Annexe A — Session minimale ``` -→ {"jsonrpc":"2.0","id":1,"method":"Handshake","params":{"protocolVersion":"1.2","engine":"etm-powersync-energy-plugin-etm/1.15.2+etm71"}} -← {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"1.2","name":"mon-optimiseur","planning":false}} +→ {"jsonrpc":"2.0","id":1,"method":"Handshake","params":{"protocolVersion":"1.3","engine":"etm-powersync-energy-plugin-etm/"}} +← {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"1.3","name":"mon-optimiseur","planning":false}} → {"jsonrpc":"2.0","id":2,"method":"GetPlan","params":{ ...SurplusContext... }} ← {"jsonrpc":"2.0","id":2,"result":{"planId":"x","strategy":"custom","slots":[{ "from":"...","to":"...","actions":[...] }]}} ``` -Ce que l'optimiseur répond à ce que le moteur n'envoie jamais (1.2, §2, §3, §7) — c'est ce +Ce que l'optimiseur répond à ce que le moteur n'envoie jamais (1.2, 1.3 ; §2, §3, §7) — c'est ce qu'éprouve la suite de conformité, par une connexion de sonde : ``` @@ -1354,6 +1444,26 @@ qu'éprouve la suite de conformité, par une connexion de sonde : ← {"jsonrpc":"2.0","id":3,"error":{"code":-32602,"message":"majeure incompatible"}} puis fermeture ``` +Sur une session ouverte, un contexte invalide (1.3) — ici un `timestamp` sans décalage et rien +d'autre : + +``` +→ {"jsonrpc":"2.0","id":4,"method":"GetPlan","params":{"timestamp":"2026-10-25T02:30:00"}} +← {"jsonrpc":"2.0","id":4,"error":{"code":-32602,"message":"contexte invalide","data":{"problems":[ + {"path":"params.timestamp","problem":"format"},{"path":"params.trigger","problem":"missing"},…]}}} +``` + +L'optimiseur qui ne lit que l'instant s'arrête au premier problème ; celui qui valide tout le +contexte les liste tous, 50 au plus. Le chemin dit où ; la valeur `2026-10-25T02:30:00` n'apparaît +nulle part. + +Token faux **et** autre majeure (1.3, si l'optimiseur exige un token) : le token d'abord. + +``` +→ {"jsonrpc":"2.0","id":5,"method":"Handshake","params":{"protocolVersion":"2.0","engine":"…","token":""}} +← {"jsonrpc":"2.0","id":5,"error":{"code":-32001,"message":"token refusé"}} puis fermeture +``` + Un optimiseur conforme minimal tient en ~50 lignes de Python : répondre au handshake, renvoyer un plan à un créneau (ou `abstain`), et les erreurs du §7. @@ -1414,7 +1524,9 @@ But : comparer des optimiseurs **sur les mêmes entrées** et rejouer un inciden telle requête n'est pas envoyée (soumise au client, elle a pour réponse `request-too-long`), elle n'est pas recopiée, et le rejeu passe la ligne ; `response` : ce qui a répondu à **cette** requête — `result` brut, ou `error.code` (`no-response` si elle a été - remplacée sans réponse) ; `executed` : actions appliquées au cycle ; `latencyMs` : optimiseur + remplacée sans réponse) ; `error.code` est le code d'échec du **moteur** (`rpc-error` pour une + réponse `error` de l'optimiseur, `timeout`…), jamais le code JSON-RPC, et `error.data` n'est + jamais recopié (§7) ; `executed` : actions appliquées au cycle ; `latencyMs` : optimiseur seulement. La réponse à la requête N sert au cycle N+1 : la ligne N l'attend. - Jamais le token (il ne voyage que dans le handshake ; recopié par une réponse, il est masqué). - **Rejeu** : `tools/etm-powersync-trace-replay` relit `request`, l'envoie à un optimiseur @@ -1426,7 +1538,7 @@ But : comparer des optimiseurs **sur les mêmes entrées** et rejouer un inciden ## Annexe E — Optimiseur de référence `tools/etm-powersync-ref-optimizer/` : serveur Python minimal (bibliothèque standard seule) -conforme 1.2, `unix:///run/powersync/optimizer.sock` par défaut, `tcp://` accepté ; répondant un +conforme 1.3, `unix:///run/powersync/optimizer.sock` par défaut, `tcp://` accepté ; répondant un plan à un créneau qui reproduit le waterfall rule-based. Usages : test de bout en bout du `SocketScheduler` (suite `etm-powersync-refoptimizer`) ; point de comparaison du banc ; exemple communautaire. Mode d'emploi et modes d'injection de défauts : son `README.md`. @@ -1477,5 +1589,8 @@ Ils deviendront normatifs avec le lot qui les implémente. - **Conformité d'un optimiseur quelconque** : suite `etm-powersync-optimizer-conformance` (`tests/auto/conformance/README.md`) — le vrai client du moteur branché sur l'optimiseur que lance `ETM_POWERSYNC_OPTIMIZER_CMD`. Depuis 1.2, elle éprouve aussi, par une connexion de sonde, - les erreurs du §7 que le moteur ne provoque pas (annexe A). + les erreurs du §7 que le moteur ne provoque pas (annexe A). Depuis 1.3, la forme d'`error.data` + d'un contexte invalide et l'ordre token puis majeure : **exigés** d'un optimiseur qui s'annonce + 1.3 ou plus, **avertis** seulement pour un optimiseur 1.2 ; une valeur reçue recopiée dans + `error.data` est refusée quelle que soit la version (la règle date de 1.2). - **Prose générée** : `tools/etm-powersync-gen-protocol-doc` (`--check` dans `tools/ci-quality.sh`). diff --git a/docs/etm-powersync-optimizer-protocol.openrpc.json b/docs/etm-powersync-optimizer-protocol.openrpc.json index 16bc9a5..f637c03 100644 --- a/docs/etm-powersync-optimizer-protocol.openrpc.json +++ b/docs/etm-powersync-optimizer-protocol.openrpc.json @@ -2,12 +2,12 @@ "openrpc": "1.3.2", "info": { "title": "etm-powersync — protocole moteur ↔ optimiseur", - "version": "1.2", + "version": "1.3", "license": { "name": "GPL-3.0-or-later", "url": "https://www.gnu.org/licenses/gpl-3.0.html" }, - "description": "Contrat MACHINE du protocole entre le moteur d'énergie etm-powersync-energy-plugin-etm (client) et un optimiseur externe (serveur) — docs/OPTIMIZER_PROTOCOL.md en est la prose. Ce document est la source de la forme des messages ; il est servi par le moteur qui tourne (NymeaEnergy.GetOptimizerProtocol) et les deux côtés sont testés contre lui.\n\nCe qu'OpenRPC ne dit pas, et où cela se trouve :\n- TRANSPORT : unix:// (défaut, socket local 0660) ou tcp:// (LAN), JSON-RPC 2.0, un message par ligne (NDJSON), UTF-8 — x-etm-transport ;\n- SENS : le moteur est le CLIENT, il se connecte et envoie Handshake puis GetPlan ; la box n'ouvre aucun port. UpdateLearned va dans l'autre sens (x-etm-direction) ;\n- ERREURS de l'optimiseur (1.2) : par méthode dans methods[].errors, et pour un message qu'il ne peut pas traiter (ligne illisible, lot, ligne trop longue, méthode inconnue) dans x-etm-message-errors ;\n- HEARTBEAT IMPLICITE et machine de repli §7 — x-etm-fallback-machine ;\n- VALIDATION SÉMANTIQUE du plan : le JSON Schema dit la forme ; ce que le validateur du moteur refuse en plus (charge inconnue, créneau courant incomplet…) est listé, règle par règle, dans x-etm-semantic-rules avec son fallbackCode ;\n- listes fermées : régénérées DEPUIS LE CODE (x-etm-generated), jamais recopiées à la main.\n\nForme côté moteur (requêtes) : STRICTE — additionalProperties: false ; un champ ajouté au sérialiseur sans le schéma fait échouer les tests. Forme côté optimiseur (réponses) : ouverte — un champ inconnu est ignoré (§9) ; le schéma dit ce qu'un optimiseur CONFORME envoie, x-etm-tolerances ce que le moteur accepte en plus." + "description": "Contrat MACHINE du protocole entre le moteur d'énergie etm-powersync-energy-plugin-etm (client) et un optimiseur externe (serveur) — docs/OPTIMIZER_PROTOCOL.md en est la prose. Ce document est la source de la forme des messages ; il est servi par le moteur qui tourne (NymeaEnergy.GetOptimizerProtocol) et les deux côtés sont testés contre lui.\n\nCe qu'OpenRPC ne dit pas, et où cela se trouve :\n- TRANSPORT : unix:// (défaut, socket local 0660) ou tcp:// (LAN), JSON-RPC 2.0, un message par ligne (NDJSON), UTF-8 — x-etm-transport ;\n- SENS : le moteur est le CLIENT, il se connecte et envoie Handshake puis GetPlan ; la box n'ouvre aucun port. UpdateLearned va dans l'autre sens (x-etm-direction) ;\n- ERREURS de l'optimiseur (1.2) : par méthode dans methods[].errors, et pour un message qu'il ne peut pas traiter (ligne illisible, lot, ligne trop longue, méthode inconnue) dans x-etm-message-errors ; forme de error.data d'une erreur, quand elle est normée (1.3) : x-etm-data-schema de l'erreur, exigée à partir de x-etm-data-required-from ;\n- HEARTBEAT IMPLICITE et machine de repli §7 — x-etm-fallback-machine ;\n- VALIDATION SÉMANTIQUE du plan : le JSON Schema dit la forme ; ce que le validateur du moteur refuse en plus (charge inconnue, créneau courant incomplet…) est listé, règle par règle, dans x-etm-semantic-rules avec son fallbackCode ;\n- listes fermées : régénérées DEPUIS LE CODE (x-etm-generated), jamais recopiées à la main.\n\nForme côté moteur (requêtes) : STRICTE — additionalProperties: false ; un champ ajouté au sérialiseur sans le schéma fait échouer les tests. Forme côté optimiseur (réponses) : ouverte — un champ inconnu est ignoré (§9) ; le schéma dit ce qu'un optimiseur CONFORME envoie, x-etm-tolerances ce que le moteur accepte en plus." }, "servers": [ { @@ -33,7 +33,7 @@ { "name": "Handshake", "summary": "Ouverture de session, à chaque (re)connexion (§3).", - "description": "Envoyé par le moteur (client) dès la connexion. Délai : 5 s depuis le DÉBUT de la tentative ; au-delà, repli et nouvelle tentative au backoff. Le message porte le secret : il n'est jamais journalisé. Refus (1.2) : token absent ou différent → -32001 ; majeure incompatible → -32602 ; l'optimiseur ferme après avoir envoyé l'erreur, le moteur à sa réception. Un token envoyé alors que l'optimiseur n'en exige pas est accepté, sans erreur.", + "description": "Envoyé par le moteur (client) dès la connexion. Délai : 5 s depuis le DÉBUT de la tentative ; au-delà, repli et nouvelle tentative au backoff. Le message porte le secret : il n'est jamais journalisé. Refus (1.2) : token absent ou différent → -32001 ; majeure incompatible → -32602 ; les deux fautes dans le même handshake → -32001, le token est jugé AVANT la majeure (1.3) ; l'optimiseur ferme après avoir envoyé l'erreur, le moteur à sa réception. Un token envoyé alors que l'optimiseur n'en exige pas est accepté, sans erreur.", "paramStructure": "by-name", "params": [ { @@ -42,7 +42,7 @@ "schema": { "$ref": "#/components/schemas/ProtocolVersion" }, - "description": "Version du moteur ; seule la majeure est comparée, des deux côtés (une autre majeure : l'optimiseur répond -32602 et ferme)." + "description": "Version du moteur ; seule la majeure est comparée, des deux côtés (une autre majeure : l'optimiseur répond -32602 et ferme — -32001 si le token est faux aussi, 1.3)." }, { "name": "engine", @@ -71,13 +71,13 @@ { "code": -32001, "message": "token refusé", - "data": "Token configuré côté optimiseur, et absent ou différent dans le Handshake. Un token envoyé alors que l'optimiseur n'en exige pas est ACCEPTÉ.", + "data": "Token configuré côté optimiseur, et absent ou différent dans le Handshake — jugé AVANT la majeure (1.3) : token faux et autre majeure dans le même Handshake → -32001. Un token envoyé alors que l'optimiseur n'en exige pas est ACCEPTÉ.", "x-etm-connection": "fermée" }, { "code": -32602, "message": "majeure incompatible", - "data": "Majeure de params.protocolVersion différente de celle de l'optimiseur (1.2).", + "data": "Majeure de params.protocolVersion différente de celle de l'optimiseur (1.2), le token étant accepté ou non exigé (1.3 : le token est jugé d'abord).", "x-etm-connection": "fermée" } ], @@ -193,7 +193,11 @@ { "code": -32602, "message": "contexte invalide", - "data": "Contexte GetPlan non conforme au contrat (1.2). error.data liste les problèmes : pour chacun, le chemin du champ fautif et la nature du problème, JAMAIS la valeur reçue. Une erreur et non une abstention : un contexte invalide est un défaut du moteur, une abstention le cacherait.", + "data": "Contexte GetPlan non conforme au contrat (1.2). error.data liste les problèmes : pour chacun, le chemin du champ fautif et la nature du problème, JAMAIS la valeur reçue. Forme normée en 1.3 : InvalidContextData. Une erreur et non une abstention : un contexte invalide est un défaut du moteur, une abstention le cacherait.", + "x-etm-data-schema": { + "$ref": "#/components/schemas/InvalidContextData" + }, + "x-etm-data-required-from": "1.3", "x-etm-connection": "conservée" }, { @@ -205,12 +209,12 @@ ], "x-etm-direction": "engine→optimizer", "x-etm-timeout-s": 10, - "x-etm-errors-engine": "Toute réponse error à GetPlan, quel que soit son code : échec de la requête (rpc-error), compté par la règle des échecs consécutifs (§7)." + "x-etm-errors-engine": "Toute réponse error à GetPlan, quel que soit son code : échec de la requête (rpc-error), compté par la règle des échecs consécutifs (§7). error.data n'est PAS lu : ni décision, ni journal (le code seul), ni trace (le code de l'échec seul, rpc-error)." }, { "name": "UpdateLearned", "summary": "Estimations apprises de l'optimiseur (§8) — notification, sans id.", - "description": "Envoyée par l'OPTIMISEUR, et seulement par lui (1.2) : sens inverse des deux autres, sans id, sans réponse. Reçue PAR UN OPTIMISEUR : notification ignorée ; requête avec id → -32601 (x-etm-message-errors). État du moteur : reçue, journalisée une fois, IGNORÉE — rien n'est persisté, le contenu n'est pas lu. Forme NON NORMÉE en 1.2 : elle sera fixée avec la première stratégie qui apprend.", + "description": "Envoyée par l'OPTIMISEUR, et seulement par lui (1.2) : sens inverse des deux autres, sans id, sans réponse. Reçue PAR UN OPTIMISEUR : notification ignorée ; requête avec id → -32601 (x-etm-message-errors). État du moteur : reçue, journalisée une fois, IGNORÉE — rien n'est persisté, le contenu n'est pas lu. Forme NON NORMÉE (1.2, 1.3) : elle sera fixée avec la première stratégie qui apprend.", "paramStructure": "by-name", "params": [ { @@ -223,7 +227,7 @@ "additionalProperties": true } }, - "description": "Non normé en 1.2 : intention §8 (learned + confidence par charge)." + "description": "Non normé (1.2, 1.3) : intention §8 (learned + confidence par charge)." } ], "x-etm-direction": "optimizer→engine", @@ -234,7 +238,7 @@ "schemas": { "ProtocolVersion": { "description": "Version du protocole parlée par le moteur (GEN : OptimizerProtocol::ProtocolVersion).", - "const": "1.2" + "const": "1.3" }, "Trigger": { "description": "Cause de la requête (§5.1). GEN : SocketScheduler::knownTriggers() — du moins au plus significatif.", @@ -1804,6 +1808,62 @@ "$ref": "#/components/schemas/Plan" } ] + }, + "InvalidContextData": { + "description": "error.data de la réponse -32602 à GetPlan (§7, 1.3). Exigé d'un optimiseur qui s'annonce 1.3 ou plus ; un optimiseur 1.2 peut répondre un autre data, le moteur ne le lit pas. Aucune valeur reçue n'y figure, dans aucun champ : la forme est fermée, le chemin suit un motif, la nature est un code d'une liste fermée.", + "type": "object", + "properties": { + "problems": { + "type": "array", + "minItems": 1, + "maxItems": 50, + "items": { + "$ref": "#/components/schemas/ContextProblem" + }, + "description": "Les problèmes trouvés, de 1 à 50 ; s'il y en a davantage, les 50 premiers, et truncated." + }, + "truncated": { + "const": true, + "description": "Présent, et vrai, seulement si des problèmes ont été omis (problems en compte alors 50) ; absent sinon." + } + }, + "required": [ + "problems" + ], + "additionalProperties": false, + "x-etm-closed-why": "forme de l'optimiseur fermée à dessein : aucune valeur reçue ne doit pouvoir s'y glisser" + }, + "ContextProblem": { + "description": "Un problème du contexte GetPlan (1.3) : où, et de quelle nature — jamais la valeur reçue.", + "type": "object", + "properties": { + "path": { + "type": "string", + "pattern": "^params(\\.[A-Za-z0-9_]+|\\[[0-9]+\\])*$", + "description": "Chemin du champ fautif depuis params : noms séparés par des points, indices de liste entre crochets (params.forecast.pv[3].from). Champ absent : son chemin attendu." + }, + "problem": { + "$ref": "#/components/schemas/ContextProblemCode" + } + }, + "required": [ + "path", + "problem" + ], + "additionalProperties": false, + "x-etm-closed-why": "forme de l'optimiseur fermée à dessein : aucune valeur reçue ne doit pouvoir s'y glisser" + }, + "ContextProblemCode": { + "description": "Nature d'un problème du contexte (1.3), liste fermée : missing (obligatoire absent), type (type faux), range (hors bornes), format (chaîne mal formée : instant, durée…), enum (valeur hors liste), constraint (règle entre champs, ordre des créneaux…). Un code inconnu est ignoré par le moteur (§9).", + "type": "string", + "enum": [ + "missing", + "type", + "range", + "format", + "enum", + "constraint" + ] } } }, @@ -2105,7 +2165,7 @@ "countsAsFailure": false, "needs": [], "schemaAlsoCatches": false, - "description": "réponse error, quel que soit son code (token refusé : -32001 ; majeure incompatible : -32602, Handshake.errors) → le moteur ferme, nouvelle tentative au backoff." + "description": "réponse error, quel que soit son code (token refusé : -32001, jugé d'abord ; majeure incompatible : -32602, Handshake.errors) → le moteur ferme, nouvelle tentative au backoff." }, { "name": "invalid-handshake", @@ -2140,7 +2200,8 @@ "diagnostics, expected : non objets ignorés ; valeurs inconnues filtrées avant l'app.", "abstain : toute valeur « vraie » au sens de QVariant::toBool (true, chaîne non vide autre que « false »/« 0 », nombre non nul).", "champs inconnus (params et result) : ignorés (§9) ; force et champs réservés (annexe F) : ignorés et signalés une fois.", - "state : 3.0 accepté comme 3 (entier au sens JSON Schema)." + "state : 3.0 accepté comme 3 (entier au sens JSON Schema).", + "error.data d'une réponse error (1.3) : jamais lu — un code problem inconnu, un data d'une autre forme (optimiseur 1.2) ou absent ne changent rien : l'erreur reste un échec rpc-error." ] }, "x-etm-reason-params": { diff --git a/energyplugin/etm/scheduler/optimizerclient.h b/energyplugin/etm/scheduler/optimizerclient.h index c3a14bf..154ecf8 100644 --- a/energyplugin/etm/scheduler/optimizerclient.h +++ b/energyplugin/etm/scheduler/optimizerclient.h @@ -144,7 +144,7 @@ public: Q_ENUM(State) /*! \brief Version du protocole annoncée au handshake. Seule la majeure est comparée. */ - static constexpr const char *kProtocolVersion = "1.2"; + static constexpr const char *kProtocolVersion = "1.3"; static constexpr qint64 kHandshakeTimeoutMs = 5000; //!< §3, depuis le début de la tentative. static constexpr qint64 kRequestTimeoutMs = 10000; //!< §4, par requête \c GetPlan. static constexpr qint64 kBackoffInitialMs = 1000; //!< §2, premier délai de reconnexion. diff --git a/energyplugin/etm/scheduler/optimizerprotocol.h b/energyplugin/etm/scheduler/optimizerprotocol.h index 2ddfff1..fb2ada2 100644 --- a/energyplugin/etm/scheduler/optimizerprotocol.h +++ b/energyplugin/etm/scheduler/optimizerprotocol.h @@ -28,7 +28,7 @@ namespace OptimizerProtocol { //! Version du protocole parlée par ce sérialiseur (§3, §9). -constexpr const char *ProtocolVersion = "1.2"; +constexpr const char *ProtocolVersion = "1.3"; //! Ressource Qt du contrat OpenRPC embarqué (\c docs/etm-powersync-optimizer-protocol.openrpc.json). constexpr const char *SchemaResource = ":/etm-powersync/optimizer-protocol.openrpc.json"; diff --git a/tests/auto/optimizerclient/testoptimizerclient.cpp b/tests/auto/optimizerclient/testoptimizerclient.cpp index aa3b703..58f3b15 100644 --- a/tests/auto/optimizerclient/testoptimizerclient.cpp +++ b/tests/auto/optimizerclient/testoptimizerclient.cpp @@ -288,9 +288,9 @@ void TestOptimizerClient::testHandshakeAndPlan() QCOMPARE(hs.value("method").toString(), QStringLiteral("Handshake")); QCOMPARE(hs.value("id").toInteger(), 1); const QJsonObject params = hs.value("params").toObject(); - // Protocole 1.2 : la version ANNONCÉE au handshake est celle que le moteur sert + // Protocole 1.3 : la version ANNONCÉE au handshake est celle que le moteur sert // (GetOptimizerProtocol) et celle du contrat — deux constantes, une seule valeur. - QCOMPARE(params.value("protocolVersion").toString(), QStringLiteral("1.2")); + QCOMPARE(params.value("protocolVersion").toString(), QStringLiteral("1.3")); QCOMPARE(QString::fromLatin1(OptimizerClient::kProtocolVersion), QString::fromLatin1(OptimizerProtocol::ProtocolVersion)); QCOMPARE(params.value("engine").toString(), QStringLiteral("etm-powersync-energy-plugin-etm/9.9")); QVERIFY(!params.contains("token")); @@ -354,7 +354,7 @@ void TestOptimizerClient::testWrongMajorIsRefused() QCOMPARE(readySpy.count(), 0); QCOMPARE(client.requestPlan({}), quint64(0)); QVERIFY(waitFor([&] { return server.disconnections == 1; })); - QCOMPARE(countLog("optimiseur en 2.0, moteur en 1.2 — majeures différentes, refusé"), 1); + QCOMPARE(countLog("optimiseur en 2.0, moteur en 1.3 — majeures différentes, refusé"), 1); // Nouvelle tentative au backoff (§3), pas avant. clock.advance(999); diff --git a/tests/auto/socketscheduler/testsocketscheduler.cpp b/tests/auto/socketscheduler/testsocketscheduler.cpp index f978a17..2ba06f3 100644 --- a/tests/auto/socketscheduler/testsocketscheduler.cpp +++ b/tests/auto/socketscheduler/testsocketscheduler.cpp @@ -2341,7 +2341,7 @@ void TestSocketScheduler::testTheEngineSpeaksTheContractItServes() cycle(t0.addSecs(60 * i), -3000); const QVariantMap r = rpc("GetOptimizerProtocol"); - QCOMPARE(r.value("protocolVersion").toString(), QString("1.2")); + QCOMPARE(r.value("protocolVersion").toString(), QString("1.3")); const QJsonObject servi = QJsonObject::fromVariantMap(r.value("schema").toMap()); const QJsonObject embarque = QJsonDocument::fromJson(OptimizerProtocol::schemaDocumentBytes()).object(); QVERIFY(!embarque.isEmpty()); diff --git a/tests/tools/etm-powersync-gen-protocol-doc-test.py b/tests/tools/etm-powersync-gen-protocol-doc-test.py index b1814ee..a988b91 100644 --- a/tests/tools/etm-powersync-gen-protocol-doc-test.py +++ b/tests/tools/etm-powersync-gen-protocol-doc-test.py @@ -122,6 +122,19 @@ class TestGenProtocolDoc(unittest.TestCase): self.assertEqual(lancer(*self.args())[0], 0) self.assertIn('| `-32099` | toute | erreur ajoutée | conservée | contre-épreuve |', self.lire(self.prose)) + def test_la_forme_d_error_data_se_lit_dans_sa_table(self): + # Protocole 1.3 : le motif du chemin porte une alternative — un `|` nu fermerait la colonne, + # et la ligne aurait six cellules au lieu de cinq. Une forme de l'OPTIMISEUR fermée à dessein + # ne se dit pas « émise par le moteur ». + p = self.lire(self.prose) + ligne = next(l for l in p.splitlines() if l.startswith('| `path` | string |')) + self.assertEqual(len(ligne.replace('\\|', '').split('|')), 7, ligne) + self.assertIn('motif `^params(\\.[A-Za-z0-9_]+\\|\\[[0-9]+\\])*$`', ligne) + debut = p.index('') + bloc = p[debut:p.index('', debut)] + self.assertIn('**refusés** (forme de l\'optimiseur fermée à dessein', bloc) + self.assertNotIn('émise par le moteur', bloc) + def test_des_balises_cassees_sont_une_erreur(self): p = self.lire(self.prose) i = p.index('') diff --git a/tests/tools/etm-powersync-schema-check-test.py b/tests/tools/etm-powersync-schema-check-test.py index 7e2994b..616cd06 100644 --- a/tests/tools/etm-powersync-schema-check-test.py +++ b/tests/tools/etm-powersync-schema-check-test.py @@ -37,6 +37,9 @@ GOLDEN = sorted(glob.glob(os.path.join(RACINE, 'tests', 'auto', 'loadmodel', 'da TRACES = sorted(glob.glob(os.path.join(ICI, 'data', '*.ndjson'))) REQUETE = os.path.join(RACINE, 'tests', 'auto', 'loadmodel', 'data', 'optimizer', 'site-pv-ecs-borne-sgready.json') TOKEN = 'jeton-de-test-0123456789' +with open(SCHEMA, encoding='utf-8') as _f: + # La version du MOTEUR, celle que le contrat impose à ses Handshake (const) — jamais recopiée. + VERSION_MOTEUR = json.load(_f)['components']['schemas']['ProtocolVersion']['const'] # mode → (qui rejette, verdict sémantique attendu). « schéma: » : écart de forme ; # « règle » : la forme passe, une règle x-etm-semantic-rules refuse ; « — » : rien ne rejette. @@ -100,7 +103,7 @@ def session(mode, dossier): with open(REQUETE, encoding='utf-8') as f: params = json.load(f) s.sendall(json.dumps({'jsonrpc': '2.0', 'id': 1, 'method': 'Handshake', - 'params': {'protocolVersion': '1.2', 'engine': 'etm-powersync-energy-plugin-etm/test', + 'params': {'protocolVersion': VERSION_MOTEUR, 'engine': 'etm-powersync-energy-plugin-etm/test', 'token': TOKEN}}).encode() + b'\n') hs = lire_ligne(s, tampon) if hs and str(hs.get('result', {}).get('protocolVersion', '')).startswith('1'): diff --git a/tools/etm-powersync-gen-protocol-doc b/tools/etm-powersync-gen-protocol-doc index 2ed8603..b0a3ef7 100755 --- a/tools/etm-powersync-gen-protocol-doc +++ b/tools/etm-powersync-gen-protocol-doc @@ -145,14 +145,15 @@ class Rendu: chemin = f'{prefixe}{nom}' r = self.resoudre(s) sens = self.sens(s) or (self.sens(r) if self.nom_ref(s) and r.get('type') not in ('object',) else '') + # Un `|` dans une cellule (alternative d'un motif) fermerait la colonne : échappé. out.append(f'| `{chemin}` | {self.type_de(s)} | {"oui" if nom in requis else "non"} | ' - f'{self.valeurs(s)} | {sens} |') + f'{self.valeurs(s).replace("|", chr(92) + "|")} | {sens} |') # Un objet EN LIGNE se déroule ; un composant nommé a sa propre table. if self.nom_ref(s) is None and isinstance(s, dict) and isinstance(s.get('properties'), dict): out += self.lignes(s['properties'], s.get('required', []), chemin + '.') return out - def table(self, props, requis, titre=None, description=None, ouvert=None): + def table(self, props, requis, titre=None, description=None, ouvert=None, ferme_pourquoi=None): out = [] if titre: out.append(f'**{titre}**' + (f' — {description}' if description else '')) @@ -162,8 +163,13 @@ class Rendu: out += self.lignes(props, requis) if ouvert is not None: out.append('') - out.append('Champs non déclarés : ' + ('**refusés** (forme émise par le moteur, fermée).' if ouvert is False - else 'ignorés (§9).')) + if ouvert is not False: + out.append('Champs non déclarés : ignorés (§9).') + else: + # Une forme fermée est d'ordinaire celle qu'émet le moteur ; une forme de l'optimiseur + # fermée À DESSEIN dit pourquoi (`x-etm-closed-why`, 1.3 : error.data). + out.append('Champs non déclarés : **refusés** (' + + (ferme_pourquoi or 'forme émise par le moteur, fermée') + ').') return out def composant(self, nom): @@ -184,7 +190,8 @@ class Rendu: if 'properties' not in s: return [f'`{nom}` : {self.type_de(s)} — {self.valeurs(s)}. {self.sens(s)}'.rstrip()] out = self.table(s['properties'], s.get('required', []), titre=f'`{nom}`', - description=self.sens(s) or None, ouvert=s.get('additionalProperties', True)) + description=self.sens(s) or None, ouvert=s.get('additionalProperties', True), + ferme_pourquoi=s.get('x-etm-closed-why')) if 'anyOf' in s and all(set(b) == {'required'} for b in s['anyOf']): out.append('Au moins un de : ' + ', '.join('`' + '`+`'.join(b['required']) + '`' for b in s['anyOf']) + '.') return out From 8c7c33be39a92b2ff91a17a449295b32a306d2f6 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Fri, 23 Oct 2026 11:13:17 +0200 Subject: [PATCH 2/4] =?UTF-8?q?Optimiseur=20de=20r=C3=A9f=C3=A9rence=20:?= =?UTF-8?q?=20align=C3=A9=20sur=20le=20protocole=201.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Annonce 1.3. error.data du -32602 d'un contexte invalide dans la forme normée : {"problems":[{"path":"params.timestamp","problem":"missing"|"type"|"format"}]}, 50 problèmes au plus et truncated au-delà — plus de chemin « /timestamp » ni de phrase libre. - Handshake : le token était déjà jugé avant la majeure ; l'ordre est désormais écrit et nommé. - Pour les contre-épreuves de la conformité : --announce VERSION (se présenter en 1.2) et trois modes, old-data (forme d'avant 1.3, {"problemes": [phrases]}), echo-value (recopie de la valeur reçue), major-first (la majeure jugée avant le token). Suite refoptimizer 8/8 ; test du validateur vert avec l'outil inchangé. Co-Authored-By: Claude Opus 5.5 --- tools/etm-powersync-ref-optimizer/README.md | 19 +++-- .../etm-powersync-ref-optimizer | 84 ++++++++++++++----- 2 files changed, 78 insertions(+), 25 deletions(-) diff --git a/tools/etm-powersync-ref-optimizer/README.md b/tools/etm-powersync-ref-optimizer/README.md index 3b98536..880a655 100644 --- a/tools/etm-powersync-ref-optimizer/README.md +++ b/tools/etm-powersync-ref-optimizer/README.md @@ -1,7 +1,7 @@ # `etm-powersync-ref-optimizer` — optimiseur de référence Python 3, bibliothèque standard seule, GPL-3.0 comme le dépôt. Protocole : `docs/OPTIMIZER_PROTOCOL.md` -1.2 (annexe E). Il répond un plan à **un créneau** qui reproduit le waterfall du rule-based (passe +1.3 (annexe E). Il répond un plan à **un créneau** qui reproduit le waterfall du rule-based (passe confort : même ordre, même budget, mêmes écrêtages) en ne lisant **que** le contexte `GetPlan` — aucun accès réseau sortant, aucun fichier hors le token. Usages : test de bout en bout du moteur, point de comparaison d'un banc, exemple pour qui écrit son propre optimiseur. @@ -29,16 +29,16 @@ Il écrit `ÉCOUTE ` sur la sortie standard quand il écoute (le port et son journal sur la sortie d'erreur. Plusieurs clients à la fois (un fil par connexion, aucun état partagé) : le moteur et un rejeu peuvent lui parler ensemble. SIGTERM l'arrête proprement. -## Erreurs (protocole 1.2) +## Erreurs (protocole 1.2 et 1.3) Codes JSON-RPC 2.0, plus `-32001` (protocole §7) : | Reçu | Réponse | Connexion | |---|---|---| -| `Handshake` sans le token attendu, ou avec un autre | `-32001` | fermée | -| `Handshake` d'une autre majeure | `-32602` | fermée | +| `Handshake` sans le token attendu, ou avec un autre — **même d'une autre majeure** (1.3 : le token d'abord) | `-32001` | fermée | +| `Handshake` d'une autre majeure, token accepté ou non exigé | `-32602` | fermée | | `GetPlan` avant un handshake réussi | `-32600` | gardée | -| `GetPlan` dont le `timestamp` est absent, illisible ou sans décalage | `-32602`, `error.data.problems` = `[{path, problem}]`, jamais la valeur | gardée | +| `GetPlan` dont le `timestamp` est absent, n'est pas une chaîne, ou est illisible ou sans décalage | `-32602`, `error.data` = `{"problems":[{"path":"params.timestamp","problem":"missing"\|"type"\|"format"}]}` (forme 1.3, protocole §7), jamais la valeur | gardée | | ligne non JSON | `-32700`, `id: null` | gardée | | lot `[…]` | `-32600`, `id: null`, aucun message du lot traité | gardée | | ligne de plus de 1 Mio (`\n` non compté) | `-32600`, `id: null`, jetée sans être gardée en mémoire | gardée | @@ -80,7 +80,14 @@ Le rejeu compare, cycle par cycle, les actions du créneau courant au plan enreg `constraint`) · `missing-load` (une charge disponible absente du créneau courant) · `echo-token` (recopie le token dans `reason` et `planId`) · `protocol-1.0` (optimiseur 1.0 : sans champs 1.1) · `deviate` (cascade en ordre inverse : doit diverger) · `wrong-type` (`planId` numérique : le moteur -l'accepte, le contrat OpenRPC le refuse — contre-épreuve de la suite de conformité). +l'accepte, le contrat OpenRPC le refuse — contre-épreuve de la suite de conformité) · `old-data` +(contexte invalide : `error.data` dans la forme d'avant 1.3, `{"problemes": [phrases]}`) · +`echo-value` (contexte invalide : `error.data` recopie la valeur reçue) · `major-first` (la majeure +jugée avant le token). + +`--announce VERSION` : la version annoncée au handshake, et dont la majeure est exigée du moteur +(défaut `1.3`). Avec `--announce 1.2`, il se présente en optimiseur 1.2 : la suite de conformité +n'exige plus de lui la forme 1.3 d'`error.data` ni l'ordre token puis majeure, elle avertit. `--mode-file FICHIER` : le mode est relu dans ce fichier à chaque message — un test change de mode sans reconnexion. `--capture FICHIER` : chaque message reçu et envoyé, une ligne JSON-RPC par diff --git a/tools/etm-powersync-ref-optimizer/etm-powersync-ref-optimizer b/tools/etm-powersync-ref-optimizer/etm-powersync-ref-optimizer index a3af737..1227ef2 100755 --- a/tools/etm-powersync-ref-optimizer/etm-powersync-ref-optimizer +++ b/tools/etm-powersync-ref-optimizer/etm-powersync-ref-optimizer @@ -1,11 +1,11 @@ #!/usr/bin/env python3 # SPDX-License-Identifier: GPL-3.0-or-later # Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync -"""Optimiseur de référence (OPTIMIZER_PROTOCOL.md 1.2, annexe E). +"""Optimiseur de référence (OPTIMIZER_PROTOCOL.md 1.3, annexe E). etm-powersync-ref-optimizer [--listen unix:///run/powersync/optimizer.sock | tcp://hôte:port] [--token-file FICHIER] [--mode MODE | --mode-file FICHIER] - [--slow-s N] [--horizon-s S] [--capture FICHIER] + [--slow-s N] [--horizon-s S] [--capture FICHIER] [--announce VERSION] etm-powersync-ref-optimizer --list-codes Un serveur JSON-RPC 2.0 sur NDJSON qui répond au `Handshake` et à `GetPlan` par un plan à UN @@ -37,6 +37,16 @@ Modes (`--mode`, pour les tests ; un seul à la fois) : deviate cascade servie en ordre de rang INVERSE (contre-épreuve : doit diverger) wrong-type `planId` numérique au lieu d'une chaîne : le moteur l'ACCEPTE (il le lit comme texte), le contrat OpenRPC le REFUSE — contre-épreuve de la suite de conformité + old-data contexte invalide : `error.data` dans la forme d'avant 1.3 (`{"problemes": [chaînes]}`, + celle du premier lot de l'optimiseur ETM) — refusée d'un optimiseur 1.3, avertie + d'un optimiseur 1.2 (`--announce 1.2`) + echo-value contexte invalide : `error.data` recopie la valeur reçue — refusé quelle que soit + la version annoncée (règle de 1.2) + major-first handshake : la majeure jugée AVANT le token (token faux et autre majeure → + -32602 au lieu de -32001) — refusé d'un optimiseur 1.3 + +`--announce VERSION` : la version annoncée au handshake, et dont la majeure est exigée du moteur +(défaut : celle que parle cet optimiseur, 1.3). Pour les contre-épreuves : un optimiseur 1.2. `--capture FICHIER` : chaque message reçu et envoyé, une ligne JSON-RPC par message, dans l'ordre (la session que `tools/etm-powersync-schema-check --ndjson` juge). Le token d'un handshake y est @@ -55,11 +65,14 @@ se verrait dans `ps`) et jamais affiché. S'il est configuré, un handshake sans autre, reçoit une erreur JSON-RPC -32001 puis la connexion est fermée. Sans --token-file, un token reçu est accepté sans être lu. -Erreurs (protocole 1.2, §2, §3, §7 ; codes JSON-RPC 2.0, plus -32001) : +Erreurs (protocole 1.2 et 1.3, §2, §3, §7 ; codes JSON-RPC 2.0, plus -32001) : + token absent ou différent -32001, puis fermeture — jugé AVANT la majeure (1.3) Handshake d'une autre majeure -32602, puis fermeture (comme -32001 : les deux côtés ferment) GetPlan avant un handshake réussi -32600, connexion gardée - contexte GetPlan invalide -32602, `error.data.problems` = [{path, problem}], jamais la - valeur reçue — une erreur, pas une abstention + contexte GetPlan invalide -32602, `error.data` = {"problems": [{path, problem}]} (1.3 : + chemin depuis `params`, code d'une liste fermée, 50 au plus et + `truncated` au-delà), jamais la valeur reçue — une erreur, pas + une abstention ligne non JSON -32700, id null lot JSON-RPC (`[…]`) -32600, id null, aucun message du lot traité ligne de plus de 1 Mio -32600, id null, jetée sans être gardée en mémoire @@ -88,8 +101,7 @@ import time from datetime import datetime, timedelta ENDPOINT_DEFAUT = 'unix:///run/powersync/optimizer.sock' -VERSION = '1.2' -MAJEURE = VERSION.split('.')[0] +VERSION = '1.3' NOM = 'etm-powersync-ref-optimizer' # Nom annoncé au handshake : `powersync-optimizer`, l'exemple GPL de référence. `etm-optimizer` # est le nom de l'optimiseur ETM, propriétaire, hors de ce dépôt. @@ -101,9 +113,11 @@ CODE_REQUETE_INVALIDE = -32600 CODE_METHODE_INCONNUE = -32601 CODE_PARAMS_INVALIDES = -32602 TROP_LONGUE = object() # lire_ligne() : ligne de plus de LIGNE_MAX octets, déjà jetée +PROBLEMES_MAX = 50 # §7, 1.3 : au-delà, les 50 premiers et `truncated` MODES = ['normal', 'abstain', 'slow', 'invalid', 'expired', 'wrong-major', 'drop', 'unknown-load', - 'forbidden-kind', 'missing-load', 'echo-token', 'protocol-1.0', 'deviate', 'wrong-type'] + 'forbidden-kind', 'missing-load', 'echo-token', 'protocol-1.0', 'deviate', 'wrong-type', + 'old-data', 'echo-value', 'major-first'] # Catalogue (energyplugin/etm/types/decisionreason.cpp) : codes qu'un optimiseur peut émettre et # leurs clés de paramètres documentées (INTERFACE.md). Le test d'intégration les confronte au @@ -328,13 +342,32 @@ def iso(t): def problemes_contexte(ctx): - """Ce que cet optimiseur ne peut pas lire dans un contexte GetPlan : [{path, problem}], jamais - la valeur reçue (§7, 1.2). Seul `timestamp` est exigé — tout le reste se lit avec un défaut.""" - if instant(ctx.get('timestamp')) is None: - return [{'path': '/timestamp', 'problem': 'absent, illisible ou sans décalage horaire'}] + """Ce que cet optimiseur ne peut pas lire dans un contexte GetPlan : [{path, problem}] (§7, 1.3), + chemin depuis `params`, nature dans la liste fermée — jamais la valeur reçue. Seul `timestamp` + est exigé : tout le reste se lit avec un défaut.""" + if 'timestamp' not in ctx: + return [{'path': 'params.timestamp', 'problem': 'missing'}] + if not isinstance(ctx['timestamp'], str): + return [{'path': 'params.timestamp', 'problem': 'type'}] + if instant(ctx['timestamp']) is None: + return [{'path': 'params.timestamp', 'problem': 'format'}] # illisible, ou sans décalage return [] +def donnees_contexte_invalide(problemes, ctx, mode): + """`error.data` du -32602 de GetPlan. Forme 1.3 : les 50 premiers problèmes, `truncated` au-delà. + Les modes old-data et echo-value fabriquent ce qu'un optimiseur NON conforme répondrait.""" + if mode == 'old-data': + # Forme du premier lot de l'optimiseur ETM (avant 1.3) : des phrases, sans liste fermée. + return {'problemes': [f'{p["path"]} : {p["problem"]}' for p in problemes]} + data = {'problems': problemes[:PROBLEMES_MAX]} + if len(problemes) > PROBLEMES_MAX: + data['truncated'] = True + if mode == 'echo-value': + data['problems'][0] = dict(data['problems'][0], received=ctx.get('timestamp')) + return data + + def plan(ctx, mode, horizon_s, token): """La réponse à un GetPlan (`result`) ; le contexte a passé problemes_contexte().""" t0 = time.monotonic() @@ -498,19 +531,27 @@ class Connexion(socketserver.StreamRequestHandler): mode = mode_courant(cfg) if methode == 'Handshake': attendu = lire_token(cfg.token_file) - if attendu is not None and params.get('token') != attendu: + version = params.get('protocolVersion') + token_refuse = attendu is not None and params.get('token') != attendu + majeure_refusee = not isinstance(version, str) or version.split('.')[0] != cfg.announce.split('.')[0] + # §3, 1.3 : le token d'abord — un pair sans le secret n'apprend pas quelle majeure on + # parle. Le mode major-first inverse l'ordre (contre-épreuve de la conformité). + if mode == 'major-first' and majeure_refusee: + journal(f'handshake refusé : majeure incompatible ({str(version)[:32]}), mode major-first') + self.erreur(ident, CODE_PARAMS_INVALIDES, 'majeure incompatible') + return + if token_refuse: # Le token reçu n'est jamais recopié ni journalisé. journal('handshake refusé : token absent ou différent') self.erreur(ident, CODE_TOKEN_REFUSE, 'token refusé') return - version = params.get('protocolVersion') - if not isinstance(version, str) or version.split('.')[0] != MAJEURE: + if majeure_refusee: journal(f'handshake refusé : majeure incompatible ({str(version)[:32]})') self.erreur(ident, CODE_PARAMS_INVALIDES, 'majeure incompatible') return token = attendu pret = True - reponse = {'protocolVersion': VERSION, 'name': NOM_ANNONCE, 'planning': False, + reponse = {'protocolVersion': cfg.announce, 'name': NOM_ANNONCE, 'planning': False, 'capabilities': {'resolutionS': cfg.horizon_s, 'kinds': ['setpoint', 'state'], 'usesForecast': [], 'reasonCodes': True}} if mode == 'wrong-major': @@ -526,8 +567,9 @@ class Connexion(socketserver.StreamRequestHandler): continue problemes = problemes_contexte(params) if problemes: - journal(f'contexte invalide : {", ".join(p["path"] for p in problemes)}') - self.erreur(ident, CODE_PARAMS_INVALIDES, 'contexte invalide', {'problems': problemes}) + journal(f'contexte invalide : {", ".join(p["path"] + " " + p["problem"] for p in problemes)}') + self.erreur(ident, CODE_PARAMS_INVALIDES, 'contexte invalide', + donnees_contexte_invalide(problemes, params, mode)) continue if mode == 'drop': journal('mode drop : connexion fermée sans réponse') @@ -582,7 +624,7 @@ def ecouter(endpoint): def main(argv=None): - p = argparse.ArgumentParser(prog=NOM, description='Optimiseur de référence (protocole 1.2, annexe E).') + p = argparse.ArgumentParser(prog=NOM, description='Optimiseur de référence (protocole 1.3, annexe E).') p.add_argument('--listen', default=ENDPOINT_DEFAUT, help=f'unix:///chemin ou tcp://hôte:port (défaut : {ENDPOINT_DEFAUT})') p.add_argument('--token-file', help='fichier contenant le token attendu (jamais en argument)') @@ -592,12 +634,16 @@ def main(argv=None): p.add_argument('--horizon-s', type=int, default=300, help='durée du créneau et de validUntil, en s (défaut 300)') p.add_argument('--capture', help='écrit chaque message reçu et envoyé dans ce fichier (NDJSON)') + p.add_argument('--announce', default=VERSION, + help=f'version annoncée au handshake, sa majeure exigée du moteur (défaut : {VERSION})') p.add_argument('--list-codes', action='store_true', help='écrit le catalogue des codes émissibles (JSON) et sort') args = p.parse_args(argv) if args.list_codes: print(json.dumps(CODES_EMISSIBLES, sort_keys=True)) return 0 + if not args.announce.split('.')[0].isdigit(): + p.error(f'--announce : version semver attendue, pas « {args.announce} »') def arreter(*_): raise KeyboardInterrupt signal.signal(signal.SIGTERM, arreter) # arrêt propre : le socket unix est retiré From d961b73ed7ac1cfb7ec00035682060c18a03563a Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Fri, 23 Oct 2026 11:13:57 +0200 Subject: [PATCH 3/4] Validateur du contrat : juge error.data selon la version de l'optimiseur (protocole 1.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tools/etm-powersync-schema-check : - lit methods[].errors[].x-etm-data-schema : error.data d'une réponse error de ce code est jugé contre ce schéma (audité comme les autres) ; exigé à partir de x-etm-data-required-from, la version étant celle que l'optimiseur annonce dans SON Handshake ; en deçà, écart écrit en AVERTISSEMENT, sans effet sur le code de sortie ; version inconnue = celle du contrat ; - truncated n'accompagne que 50 problèmes exactement ; - aucune valeur reçue recopiée dans error.data : écart dès 1.2 (règle de 1.2). Le contrôle ne porte que sur les valeurs qu'un chemin conforme ne peut pas contenir — il ne peut pas accuser un chemin conforme (domain « battery » / params.battery) ; - --error (un objet error, avec --request et --handshake) et --responses-only (session de sonde, requêtes fautives à dessein) ; RÉSUMÉ compte les avertissements. Tests : forme exigée en 1.3 et avertie en 1.2 sur de vraies sessions de l'optimiseur de référence (normal, old-data, echo-value × 1.3, 1.2), forme ligne à ligne, recopie sans faux positif, contre-épreuve « sans x-etm-data-schema, rien n'est jugé ». Les cinq nouveaux tests échouent avec le validateur précédent. Co-Authored-By: Claude Opus 5.5 --- .../tools/etm-powersync-schema-check-test.py | 169 +++++++++++++++++- tools/README.md | 7 + tools/etm-powersync-schema-check | 168 +++++++++++++++-- 3 files changed, 332 insertions(+), 12 deletions(-) diff --git a/tests/tools/etm-powersync-schema-check-test.py b/tests/tools/etm-powersync-schema-check-test.py index 616cd06..2a42582 100644 --- a/tests/tools/etm-powersync-schema-check-test.py +++ b/tests/tools/etm-powersync-schema-check-test.py @@ -13,7 +13,9 @@ Trois choses : « bons » sont valides, chaque mode « mauvais » est rejeté par le schéma OU par une règle sémantique nommée — et laquelle (`TABLE_MODES`, recopiée au rapport) ; 3. le validateur lui-même ne peut pas se taire : un mot-clé non géré, une règle orpheline, un - champ ajouté, un mauvais type le font échouer. + champ ajouté, un mauvais type le font échouer ; +4. `error.data` du -32602 de GetPlan (protocole 1.3) : forme exigée d'un optimiseur 1.3, avertie + d'un optimiseur 1.2, recopie d'une valeur reçue refusée des deux. """ import copy @@ -59,8 +61,16 @@ TABLE_MODES = { 'unknown-load': ('règle', 'unknown-load'), 'forbidden-kind': ('règle', 'kind-mismatch'), 'missing-load': ('règle', 'incomplete-slot'), + # Modes 1.3 : sur un contexte VALIDE et le bon token, ils se comportent comme `normal`. + 'old-data': ('—', 'valide'), + 'echo-value': ('—', 'valide'), + 'major-first': ('—', 'valide'), } +# Une valeur que nul chemin conforme ne peut contenir (tiret, deux-points) : si elle apparaît dans +# error.data, c'est une recopie. +MARQUEUR = 'valeur-sonde:7Qz9' + def verifier(*args): p = subprocess.run([sys.executable, '-I', OUTIL, *args], capture_output=True, text=True, timeout=60) @@ -117,6 +127,33 @@ def session(mode, dossier): return capture +def session_contexte_invalide(dossier, mode, annonce, params): + """Handshake accepté puis UN GetPlan de contexte invalide ; rend le fichier capturé.""" + sock_path = os.path.join(dossier, 'opt.sock') + capture = os.path.join(dossier, f'invalide-{mode}-{annonce}.ndjson') + proc = subprocess.Popen([sys.executable, '-I', '-B', REF, '--listen', f'unix://{sock_path}', '--mode', mode, + '--announce', annonce, '--capture', capture], + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True) + try: + assert proc.stdout.readline().startswith('ÉCOUTE'), 'optimiseur de référence muet' + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + s.connect(sock_path) + tampon = [b''] + s.sendall(json.dumps({'jsonrpc': '2.0', 'id': 1, 'method': 'Handshake', + 'params': {'protocolVersion': VERSION_MOTEUR, + 'engine': 'etm-powersync-energy-plugin-etm/test'}}).encode() + b'\n') + assert lire_ligne(s, tampon)['result']['protocolVersion'] == annonce + s.sendall(json.dumps({'jsonrpc': '2.0', 'id': 2, 'method': 'GetPlan', 'params': params}).encode() + b'\n') + reponse = lire_ligne(s, tampon) + assert reponse['error']['code'] == -32602, reponse + s.close() + finally: + proc.terminate() + proc.wait(5) + proc.stdout.close() + return capture + + class TestCeQueLeMoteurEmet(unittest.TestCase): def test_les_references_du_serialiseur_sont_conformes(self): self.assertGreaterEqual(len(GOLDEN), 4, 'fichiers de référence du sérialiseur introuvables') @@ -239,5 +276,135 @@ class TestLeValidateurNeSeTaitPas(unittest.TestCase): self.assertNotIn('uniqueItems', sortie) +class TestDonneesDErreur(unittest.TestCase): + """error.data du -32602 de GetPlan (protocole 1.3), sur de vraies sessions de l'optimiseur de + référence. La session porte un GetPlan de contexte invalide : le validateur la juge SANS + --semantic (sinon la réponse error est un refus, quel que soit son data).""" + + # (mode, version annoncée) → (code de sortie, écarts > 0, avertissements > 0) + ATTENDU = { + ('normal', '1.3'): (0, False, False), + ('normal', '1.2'): (0, False, False), + ('old-data', '1.3'): (1, True, False), # forme d'avant 1.3, annoncée 1.3 : refusée + ('old-data', '1.2'): (0, False, True), # la même, annoncée 1.2 : avertie + ('echo-value', '1.3'): (1, True, False), + ('echo-value', '1.2'): (1, True, True), # forme avertie, RECOPIE refusée (règle de 1.2) + } + + def test_forme_exigee_en_1_3_avertie_en_1_2_recopie_refusee(self): + params = copy.deepcopy(json.load(open(REQUETE, encoding='utf-8'))) + params['timestamp'] = MARQUEUR # sans décalage, illisible : `format` + obtenu = {} + with tempfile.TemporaryDirectory() as d: + for (mode, annonce) in self.ATTENDU: + capture = session_contexte_invalide(d, mode, annonce, params) + code, r, sortie = verifier('--ndjson', capture, '--responses-only') + self.assertEqual(r['comptes'].get('GetPlan.error'), 1, sortie) + self.assertNotIn('GetPlan.params', r['comptes']) + self.assertEqual(r['comptes'].get('GetPlan.error.data'), 1, sortie) + obtenu[(mode, annonce)] = (code, r['ecarts'] > 0, r['avertissements'] > 0) + print(f' {mode:11s} {annonce} → code {code}, écarts {r["ecarts"]}, avertissements ' + f'{r["avertissements"]}', file=sys.stderr) + if mode == 'echo-value': + self.assertIn('recopie la valeur reçue en params.timestamp', sortie) + self.assertNotIn(MARQUEUR, sortie, 'le validateur recopie lui-même la valeur') + if (mode, annonce) == ('old-data', '1.2'): + self.assertIn('AVERTISSEMENT', sortie) + self.assertIn('à aligner avant de s\'annoncer 1.3', sortie) + self.assertEqual(obtenu, self.ATTENDU) + + def test_la_forme_vient_du_contrat(self): + # Contre-épreuve : sans x-etm-data-schema, la forme d'avant 1.3 annoncée 1.3 passe — c'est + # bien le contrat qui juge, pas une règle cachée dans l'outil. + with open(SCHEMA, encoding='utf-8') as f: + s = json.load(f) + for e in next(m for m in s['methods'] if m['name'] == 'GetPlan')['errors']: + e.pop('x-etm-data-schema', None) + e.pop('x-etm-data-required-from', None) + with tempfile.TemporaryDirectory() as d: + schema = os.path.join(d, 's.json') + with open(schema, 'w', encoding='utf-8') as f: + json.dump(s, f, ensure_ascii=False) + capture = session_contexte_invalide(d, 'old-data', '1.3', {'timestamp': MARQUEUR}) + self.assertEqual(verifier('--ndjson', capture, '--responses-only')[0], 1) + code, r, sortie = verifier('--schema', schema, '--ndjson', capture, '--responses-only') + self.assertEqual(code, 0, sortie) + self.assertNotIn('GetPlan.error.data', r['comptes']) + + def ecrire(self, d, nom, contenu): + chemin = os.path.join(d, nom) + with open(chemin, 'w', encoding='utf-8') as f: + json.dump(contenu, f, ensure_ascii=False) + return chemin + + def juger(self, d, data, requete=None, version='1.3'): + args = ['--method', 'GetPlan', '--error', + self.ecrire(d, 'e.json', {'code': -32602, 'message': 'contexte invalide', 'data': data}), + '--handshake', self.ecrire(d, 'h.json', {'protocolVersion': version})] + if requete is not None: + args += ['--request', self.ecrire(d, 'q.json', requete)] + return verifier(*args) + + def test_la_forme_ligne_a_ligne(self): + p = {'path': 'params.loads[0].id', 'problem': 'missing'} + cas = [ + ({'problems': [p]}, 0, None), + ({'problems': [{'path': 'params', 'problem': 'type'}]}, 0, None), + ({'problems': [{'path': 'params.loads[0].declared.estimatedPowerW.3', 'problem': 'range'}]}, 0, None), + ({'problems': [p] * 50, 'truncated': True}, 0, None), + ({'problems': []}, 1, '[minItems]'), + ({'problems': [p] * 51}, 1, '[maxItems]'), + ({'problems': [p] * 50, 'truncated': False}, 1, '[const]'), + ({'problems': [p] * 3, 'truncated': True}, 1, '[x-etm-truncated]'), + ({'problems': [dict(p, problem='absent')]}, 1, '[enum]'), + ({'problems': [dict(p, path='/loads/0/id')]}, 1, '[pattern]'), + ({'problems': [dict(p, path='loads[0].id')]}, 1, '[pattern]'), + ({'problems': [dict(p, path='params.loads.0.id ')]}, 1, '[pattern]'), + ({'problems': [dict(p, value=3)]}, 1, '[additionalProperties]'), + ({'problems': [p], 'detail': 'x'}, 1, '[additionalProperties]'), + ({'problems': [{'path': 'params.timestamp'}]}, 1, '[required]'), + ({'problemes': ['params.timestamp : format']}, 1, '[required]'), + ('contexte invalide', 1, '[type]'), + ] + with tempfile.TemporaryDirectory() as d: + for data, code_attendu, mot in cas: + code, r, sortie = self.juger(d, data) + self.assertEqual(code, code_attendu, f'{data!r}\n{sortie}') + if mot: + self.assertIn(mot, sortie, data) + # data absent : écart en 1.3 + chemin = self.ecrire(d, 'e.json', {'code': -32602, 'message': 'contexte invalide'}) + code, _, sortie = verifier('--method', 'GetPlan', '--error', chemin) + self.assertEqual(code, 1, sortie) + self.assertIn('« data » manquant', sortie) + # Un autre code d'erreur n'a pas de forme normée : rien à juger. + chemin = self.ecrire(d, 'e.json', {'code': -32603, 'message': 'erreur interne', 'data': [1, 'x']}) + code, r, sortie = verifier('--method', 'GetPlan', '--error', chemin) + self.assertEqual((code, r['ecarts']), (0, 0), sortie) + + def test_la_recopie_ne_peut_pas_accuser_un_chemin_conforme(self): + # Valeurs de la requête qui sont AUSSI des noms de champ (domain « battery », trigger + # « forecast », meter.source « meter ») : un chemin conforme les contient, ce n'est pas une + # recopie. Une valeur à tiret ou à deux-points recopiée, elle, l'est. + requete = copy.deepcopy(json.load(open(REQUETE, encoding='utf-8'))) + requete['trigger'] = 'forecast' + requete['loads'][0]['domain'] = 'battery' + data = {'problems': [{'path': 'params.battery.socPercent', 'problem': 'range'}, + {'path': 'params.forecast.pv[3].from', 'problem': 'format'}, + {'path': 'params.meter.source', 'problem': 'enum'}]} + with tempfile.TemporaryDirectory() as d: + code, r, sortie = self.juger(d, data, requete) + self.assertEqual(code, 0, sortie) + ident = requete['loads'][0]['id'] + self.assertRegex(ident, '[^A-Za-z0-9_]') + code, r, sortie = self.juger(d, {'problemes': [f'charge {ident} : priorité hors bornes']}, requete, '1.2') + self.assertEqual(code, 1, sortie) + self.assertIn('recopie la valeur reçue en params.loads[0].id', sortie) + self.assertNotIn(ident, sortie.split('RÉSUMÉ')[0]) + # Avant 1.2, la règle n'existait pas : avertie seulement. + code, r, sortie = self.juger(d, {'problemes': [f'charge {ident}']}, requete, '1.1') + self.assertEqual((code, r['ecarts'], r['avertissements'] > 0), (0, 0, True), sortie) + + if __name__ == '__main__': unittest.main(verbosity=2) diff --git a/tools/README.md b/tools/README.md index a4ca463..291886f 100644 --- a/tools/README.md +++ b/tools/README.md @@ -60,6 +60,9 @@ Python 3, bibliothèque standard seule, aucun accès réseau. Le contrat : ./tools/etm-powersync-schema-check --method GetPlan --params requete.json ./tools/etm-powersync-schema-check --method GetPlan --result plan.json --request requete.json --semantic ./tools/etm-powersync-schema-check --ndjson session.ndjson --semantic +./tools/etm-powersync-schema-check --ndjson sonde.ndjson --responses-only # requêtes fautives à dessein +./tools/etm-powersync-schema-check --method GetPlan --error erreur.json --request requete.json \ + --handshake handshake.json # error.data d'un -32602 (protocole 1.3) ./tools/etm-powersync-schema-check --trace etm-powersync-optimizer-trace-2026-10-17.ndjson --semantic ./tools/etm-powersync-schema-check --subset # le sous-ensemble de JSON Schema géré @@ -77,6 +80,10 @@ ETM_POWERSYNC_CODE_LISTS=/tmp/listes.json /tests/auto/protocolschema/etm- mot-clé est une erreur de schéma (code 2), jamais ignoré. - **`--semantic`** applique `x-etm-semantic-rules` : ce que le validateur du moteur refuse en plus de la forme, dans son ordre, sous le code qu'il publie (`fallbackCode`). +- **`error.data`** (protocole 1.3) : la forme d'`error.data` d'une erreur, quand le contrat la norme + (`x-etm-data-schema`, `-32602` de `GetPlan`), est exigée d'un optimiseur qui s'annonce + `x-etm-data-required-from` ou plus, et seulement AVERTIE en deçà (ligne `AVERTISSEMENT`, sans effet + sur le code de sortie) ; une valeur reçue recopiée dans `error.data` est un écart dès 1.2. - **Code de sortie** : 0 conforme ; 1 écart de forme ou refus sémantique ; 2 erreur d'usage ou de schéma. - Conformité d'un optimiseur de bout en bout, avec le vrai moteur : suite diff --git a/tools/etm-powersync-schema-check b/tools/etm-powersync-schema-check index 4869bad..f6a904d 100755 --- a/tools/etm-powersync-schema-check +++ b/tools/etm-powersync-schema-check @@ -6,7 +6,10 @@ etm-powersync-schema-check [--schema FICHIER] --method M --params FICHIER.json etm-powersync-schema-check [--schema FICHIER] --method M --result FICHIER.json [--request PARAMS.json] [--handshake RESULT.json] [--semantic] - etm-powersync-schema-check [--schema FICHIER] --ndjson SESSION.ndjson [--semantic] [--requests-only] + etm-powersync-schema-check [--schema FICHIER] --method M --error FICHIER.json + [--request PARAMS.json] [--handshake RESULT.json] + etm-powersync-schema-check [--schema FICHIER] --ndjson SESSION.ndjson [--semantic] + [--requests-only | --responses-only] etm-powersync-schema-check [--schema FICHIER] --trace TRACE.ndjson [--semantic] etm-powersync-schema-check --subset @@ -28,18 +31,35 @@ Ce que l'outil lit en plus du JSON Schema : - `x-etm-reason-params` : clés de `reasonParams` admises par `reasonCode` (contrôle de forme) ; - `x-etm-semantic-rules` (avec `--semantic`) : ce que le validateur du moteur refuse EN PLUS de la forme, dans le même ordre, sous le même nom. Une règle du schéma sans implantation ici, ou - l'inverse, est une erreur de schéma (code 2) : le contrôle ne disparaît pas en silence. + l'inverse, est une erreur de schéma (code 2) : le contrôle ne disparaît pas en silence ; +- `methods[].errors[].x-etm-data-schema` (protocole 1.3) : la forme de `error.data` d'une réponse + error de ce code — `InvalidContextData` pour le `-32602` de `GetPlan`. Exigée d'un optimiseur qui + s'annonce `x-etm-data-required-from` ou plus (version lue dans SON `Handshake`) ; plus ancienne, + un écart n'est qu'un AVERTISSEMENT, qui ne change pas le code de sortie. Une version inconnue (pas + de handshake) est jugée comme celle du contrat. En plus de la forme, deux contrôles : + `truncated` n'accompagne que 50 problèmes exactement ; et AUCUNE VALEUR REÇUE n'est recopiée dans + `error.data` (§7 — règle de 1.2, donc un écart dès 1.2, jamais un avertissement). Ce dernier + contrôle porte sur les valeurs textuelles de la requête qu'un chemin conforme ne peut pas + contenir (4 caractères au moins, dont un hors `[A-Za-z0-9_]` : instants, identifiants à tiret, + libellés) : il ne peut pas accuser un chemin conforme. Une recopie de nombre ou de mot nu lui + échappe ; la forme 1.3, fermée, la rend inexprimable. Modes : - `--ndjson` : une session, un message JSON-RPC par ligne, dans les deux sens. Une réponse est rapprochée de sa requête par `id` ; un `GetPlan` est jugé avec SA requête et le dernier - `Handshake` accepté. `--requests-only` ne juge que ce qu'émet le moteur (requêtes). + `Handshake` accepté. `--requests-only` ne juge que ce qu'émet le moteur (requêtes) ; + `--responses-only` que ce qu'émet l'optimiseur (réponses) — pour une session de SONDE, dont les + requêtes sont fautives à dessein (un contexte invalide, pour juger le `-32602` qui lui répond). - `--trace` : traces de décision du moteur (annexe D) — `request` contre les params de `GetPlan` ; `response.result` contre son résultat quand la ligne vient d'un optimiseur - (`latencyMs` présent) — le plan du rule-based tracé n'est pas une réponse d'optimiseur. + (`latencyMs` présent) — le plan du rule-based tracé n'est pas une réponse d'optimiseur. Une + erreur n'y porte que le code d'échec du moteur : rien à juger de son `data`. +- `--error` : l'objet `error` d'une réponse à `--method` ; son `data` est jugé comme en session, + la version tirée de `--handshake` (le `result` du Handshake), la requête de `--request`. -Sortie : une ligne par écart, puis `RÉSUMÉ {json}` (comptes par méthode et partie, écarts, -verdicts sémantiques). Code : 0 conforme ; 1 au moins un écart de forme, ou (avec `--semantic`) un +Sortie : une ligne par écart, une ligne `AVERTISSEMENT …` par écart toléré (forme normée après la +version de l'optimiseur), puis `RÉSUMÉ {json}` (comptes par méthode et partie, écarts, +avertissements, verdicts sémantiques). Code : 0 conforme ; 1 au moins un écart de forme, ou (avec `--semantic`) un verdict qui REFUSE (`invalid`, `connection-refused`) ; 2 erreur d'usage ou de schéma. Les verdicts `abstention` et `expired` sont dits, sans échec : ce sont des réponses permises. """ @@ -125,6 +145,10 @@ class Validateur: racines.append((f'méthode {m.get("name")}, param {p.get("name")}', p.get('schema'))) if 'result' in m: racines.append((f'méthode {m.get("name")}, result', m['result'].get('schema'))) + for e in m.get('errors', []): + if 'x-etm-data-schema' in e: + racines.append((f'méthode {m.get("name")}, erreur {e.get("code")}, data', + e['x-etm-data-schema'])) for ou, s in racines: self.auditer_schema(s, ou) @@ -284,6 +308,16 @@ class Contrat: self.kinds = self.v.resoudre('#/components/schemas/ActionKind').get('enum', []) self.version = str(self.v.resoudre('#/components/schemas/ProtocolVersion').get('const', '')) self.cles_motif = doc.get('x-etm-reason-params') or {} + # (méthode, code) → (schéma de error.data, version de l'optimiseur à partir de laquelle il est exigé) + self.donnees_erreur = {} + for m in doc.get('methods', []): + for e in m.get('errors', []): + if 'x-etm-data-schema' in e: + depuis = version_lue(e.get('x-etm-data-required-from')) + if depuis is None: + raise ErreurSchema(f'{m["name"]}, erreur {e.get("code")} : x-etm-data-required-from ' + f'absent ou illisible') + self.donnees_erreur[(m['name'], e.get('code'))] = (e['x-etm-data-schema'], depuis) def methode(self, nom): if nom not in self.methodes: @@ -330,6 +364,85 @@ class Contrat: return e +# --- error.data (protocole 1.3) ---------------------------------------------------------------------- + +def version_lue(v): + """« 1.3 » → (1, 3) ; « 1 » → (1, 0) ; illisible → None.""" + if not isinstance(v, str): + return None + morceaux = v.split('.') + try: + return int(morceaux[0]), int(morceaux[1]) if len(morceaux) > 1 else 0 + except ValueError: + return None + + +TEXTE_HORS_CHEMIN = re.compile(r'[^A-Za-z0-9_]') +RECOPIE_DEPUIS = (1, 2) # « jamais la valeur reçue » : règle de 1.2 (§7) +MAX_PROBLEMES = 50 + + +def valeurs_reconnaissables(params, chemin='params'): + """Les valeurs textuelles de la requête qu'un chemin conforme ne peut pas contenir : (chemin, valeur).""" + if isinstance(params, dict): + for k, v in params.items(): + yield from valeurs_reconnaissables(v, f'{chemin}.{k}') + elif isinstance(params, list): + for i, v in enumerate(params): + yield from valeurs_reconnaissables(v, f'{chemin}[{i}]') + elif isinstance(params, str) and len(params) >= 4 and TEXTE_HORS_CHEMIN.search(params): + yield chemin, params + + +def textes(v): + """Toutes les chaînes d'un JSON, clés comprises.""" + if isinstance(v, str): + yield v + elif isinstance(v, dict): + for k, x in v.items(): + yield k + yield from textes(x) + elif isinstance(v, list): + for x in v: + yield from textes(x) + + +def juger_donnees_erreur(contrat, methode, erreur, requete, version): + """error.data d'une réponse error : (écarts, avertissements), chacun (chemin, mot-clé, message). + + `version` : protocolVersion annoncée par l'optimiseur au handshake (None : inconnue, jugée comme + celle du contrat). La forme n'est exigée qu'à partir de x-etm-data-required-from ; la recopie + d'une valeur reçue est un écart dès 1.2.""" + if not isinstance(erreur, dict): + return [], [] + forme = contrat.donnees_erreur.get((methode, erreur.get('code'))) + if forme is None: + return [], [] + schema, depuis = forme + v = version_lue(version) or version_lue(contrat.version) + if 'data' not in erreur: + ecarts_forme = [('/error', 'required', '« data » manquant')] + else: + data = erreur['data'] + ecarts_forme = contrat.v.valider(data, schema, '/error/data') + if isinstance(data, dict) and data.get('truncated') is True and isinstance(data.get('problems'), list) \ + and len(data['problems']) != MAX_PROBLEMES: + ecarts_forme.append(('/error/data/truncated', 'x-etm-truncated', + f'truncated avec {len(data["problems"])} problème(s) : seulement quand ' + f'{MAX_PROBLEMES} sont donnés et d\'autres omis')) + recopies = [] + if 'data' in erreur and isinstance(requete, dict): + vus = list(textes(erreur['data'])) + for chemin, valeur in valeurs_reconnaissables(requete): + if any(valeur in t for t in vus): + recopies.append(('/error/data', 'x-etm-no-echo', + f'recopie la valeur reçue en {chemin} — jamais la valeur reçue (§7)')) + ecarts, avertissements = [], [] + (ecarts if v >= depuis else avertissements).extend(ecarts_forme) + (ecarts if v >= RECOPIE_DEPUIS else avertissements).extend(recopies) + return ecarts, avertissements + + # --- Règles sémantiques : le validateur du moteur, transcrit dans son ordre ----------------------- REGLES_IMPLANTEES = ['abstain', 'malformed-plan', 'empty-plan', 'bad-time', 'slots-unordered', @@ -500,6 +613,7 @@ class Bilan: self.semantique = semantique self.comptes = {} self.ecarts = [] + self.avertissements = [] self.verdicts = {} self.refus = 0 @@ -512,6 +626,22 @@ class Bilan: self.ecarts.append({'ou': ou, 'partie': partie, 'chemin': chemin, 'mot': mot, 'message': msg}) print(f'{ou} {partie} {chemin} : {msg} [{mot}]') + def avertir(self, ou, partie, ecarts, pourquoi): + for chemin, mot, msg in ecarts: + self.avertissements.append({'ou': ou, 'partie': partie, 'chemin': chemin, 'mot': mot, 'message': msg}) + print(f'AVERTISSEMENT {ou} {partie} {chemin} : {msg} [{mot}] — {pourquoi}') + + def juger_erreur(self, ou, methode, erreur, requete, version): + ecarts, avertissements = juger_donnees_erreur(self.c, methode, erreur, requete, version) + if (methode, erreur.get('code') if isinstance(erreur, dict) else None) not in self.c.donnees_erreur: + return + partie = f'{methode}.error.data' + self.noter(ou, partie, ecarts) + forme = self.c.donnees_erreur[(methode, erreur['code'])] + self.avertir(ou, partie, avertissements, + f'optimiseur en {str(version)[:16]}, forme exigée à partir de ' + f'{forme[1][0]}.{forme[1][1]} : à aligner avant de s\'annoncer {forme[1][0]}.{forme[1][1]}') + def verdict(self, ou, code): if code is None: code = 'valide' @@ -529,6 +659,7 @@ class Bilan: def resume(self): print('RÉSUMÉ ' + json.dumps({'comptes': self.comptes, 'ecarts': len(self.ecarts), + 'avertissements': len(self.avertissements), 'verdicts': self.verdicts, 'refus': self.refus}, ensure_ascii=False, sort_keys=True)) @@ -553,10 +684,11 @@ class ErreurLigne: self.msg = msg -def session(b, chemin, requetes_seules): +def session(b, chemin, requetes_seules, reponses_seules=False): """Un message JSON-RPC par ligne ; réponses rapprochées par id.""" attente = {} # id → (méthode, params) handshake = None # dernier result de Handshake accepté + version = None # protocolVersion de l'optimiseur, lue dans ce result (error.data, 1.3) for n, m in lignes(chemin): ou = f'ligne {n}' if isinstance(m, ErreurLigne) or not isinstance(m, dict): @@ -569,7 +701,8 @@ def session(b, chemin, requetes_seules): if nom not in b.c.methodes: b.noter(ou, 'enveloppe', [('/method', 'enum', f'méthode « {nom} » absente du contrat')]) continue - b.noter(ou, f'{nom}.params', b.c.valider_params(nom, m.get('params', {}))) + if not reponses_seules: + b.noter(ou, f'{nom}.params', b.c.valider_params(nom, m.get('params', {}))) if m.get('id') is not None: attente[m['id']] = (nom, m.get('params')) continue @@ -587,9 +720,11 @@ def session(b, chemin, requetes_seules): b.verdict(ou, code) if code is None: handshake = m['result'] + version = handshake.get('protocolVersion') elif 'error' in m: b.compter(f'{nom}.error') print(f'{ou} {nom} : réponse error (compte comme un échec de la requête, rpc-error)') + b.juger_erreur(ou, nom, m['error'], params, version) if b.semantique: b.refus += 1 else: @@ -623,12 +758,15 @@ def main(argv=None): p.add_argument('--method', help='méthode du message (--params / --result)') p.add_argument('--params', help='fichier JSON : les params d\'une requête') p.add_argument('--result', help='fichier JSON : le result d\'une réponse') + p.add_argument('--error', help='fichier JSON : l\'objet error d\'une réponse (forme de son data)') p.add_argument('--request', help='fichier JSON : les params de la requête (règles sémantiques de GetPlan)') p.add_argument('--handshake', help='fichier JSON : le result du Handshake (kinds annoncés)') p.add_argument('--ndjson', help='session JSON-RPC, un message par ligne') p.add_argument('--trace', help='traces de décision du moteur (annexe D)') p.add_argument('--semantic', action='store_true', help='applique x-etm-semantic-rules') p.add_argument('--requests-only', action='store_true', help='--ndjson : ne juge que les requêtes') + p.add_argument('--responses-only', action='store_true', + help='--ndjson : ne juge que les réponses (session de sonde, requêtes fautives à dessein)') p.add_argument('--subset', action='store_true', help='écrit le sous-ensemble géré et sort') a = p.parse_args(argv) if a.subset: @@ -639,10 +777,12 @@ def main(argv=None): contrat = Contrat(lire_json(a.schema)) b = Bilan(contrat, a.semantic) if a.ndjson: - session(b, a.ndjson, a.requests_only) + if a.requests_only and a.responses_only: + p.error('--requests-only et --responses-only s\'excluent') + session(b, a.ndjson, a.requests_only, a.responses_only) elif a.trace: trace(b, a.trace) - elif a.method and (a.params or a.result): + elif a.method and (a.params or a.result or a.error): if a.params: b.noter(a.params, f'{a.method}.params', contrat.valider_params(a.method, lire_json(a.params))) if a.result: @@ -656,8 +796,14 @@ def main(argv=None): b.verdict(a.result, juger_plan(r, req, hs, contrat.kinds)) elif a.semantic and a.method == 'Handshake': b.verdict(a.result, juger_handshake({'result': r}, contrat.version)) + if a.error: + contrat.methode(a.method) + hs = lire_json(a.handshake) if a.handshake else None + b.compter(f'{a.method}.error') + b.juger_erreur(a.error, a.method, lire_json(a.error), lire_json(a.request) if a.request else None, + hs.get('protocolVersion') if isinstance(hs, dict) else None) else: - p.error('rien à valider : --ndjson, --trace, ou --method avec --params / --result') + p.error('rien à valider : --ndjson, --trace, ou --method avec --params / --result / --error') except ErreurSchema as e: print(f'ERREUR DE SCHÉMA : {e}', file=sys.stderr) return 2 From 651181f84e5a95fe846c1eae4ec00a4c0bd07754 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Fri, 23 Oct 2026 11:15:10 +0200 Subject: [PATCH 4/4] =?UTF-8?q?Conformit=C3=A9=20:=20forme=20d'error.data?= =?UTF-8?q?=20et=20ordre=20token=20puis=20majeure=20(protocole=201.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux fonctions, par la connexion de sonde : - testAnInvalidContextIsRefusedWithItsProblems : un GetPlan de contexte invalide ({"timestamp": "valeur-sonde:7Qz9"}) → -32602 à son id, connexion gardée ; la session de sonde jugée par le validateur publié contre le contrat EMBARQUÉ (--responses-only) : forme exigée d'un optimiseur qui s'annonce 1.3 ou plus, AVERTISSEMENT seulement pour un 1.2 ; le contrôle doit avoir eu lieu (GetPlan.error.data compté) ; garde indépendante : la valeur reçue n'est pas dans error.data, quelle que soit la version. - testTheTokenIsCheckedBeforeTheMajor : token faux et autre majeure → -32001 puis fermeture ; -32602 d'un optimiseur 1.2 averti ; SKIP sans token attendu. Éprouvé contre l'optimiseur de référence : 1.3 conforme (11 verts + 1 SKIP sans token, 3/3 avec token) ; old-data annoncé 1.3 refusé, annoncé 1.2 averti ; echo-value refusé en 1.3 comme en 1.2 ; major-first refusé en 1.3, averti en 1.2 ; référence annoncée 1.2 verte sans avertissement. Co-Authored-By: Claude Opus 5.5 --- tests/auto/conformance/README.md | 28 +++- tests/auto/conformance/testconformance.cpp | 145 ++++++++++++++++++++- 2 files changed, 163 insertions(+), 10 deletions(-) diff --git a/tests/auto/conformance/README.md b/tests/auto/conformance/README.md index 58ee026..e38cf6f 100644 --- a/tests/auto/conformance/README.md +++ b/tests/auto/conformance/README.md @@ -15,11 +15,13 @@ adaptateurs, sur les mocks de la suite `simulation` — et vérifie, une fonctio | `testARefusedHandshakeIsClosedByTheOptimizer` | protocole 1.2, §3 : handshake d'une autre majeure → `-32602`, puis l'optimiseur **ferme** ; si un token est attendu, token faux → `-32001`, fermé pareil | | `testABatchIsRefused` | protocole 1.2, §2 : un lot `[…]` → **une** erreur `-32600` à `id: null`, rien du lot traité, connexion gardée | | `testALineOverTheLimitIsRefused` | protocole 1.2, §2 : une ligne de `maxLineBytes` + 1 octets → `-32600` à `id: null`, connexion gardée ; une ligne d'exactement `maxLineBytes` est lue | +| `testAnInvalidContextIsRefusedWithItsProblems` | protocole 1.3, §7 : un `GetPlan` dont le contexte est invalide (`{"timestamp": "valeur-sonde:7Qz9"}`) → `-32602` à son `id`, connexion **gardée** ; `error.data` jugé par le validateur publié contre le contrat embarqué (`--responses-only`) : forme `{"problems":[{path, problem}]}` **exigée** d'un optimiseur qui s'annonce 1.3 ou plus, **avertie** (`AVERTISSEMENT`, test vert) d'un optimiseur 1.2 ; la valeur reçue recopiée dans `error.data` est **refusée** quelle que soit la version (règle de 1.2) | +| `testTheTokenIsCheckedBeforeTheMajor` | protocole 1.3, §3 : token faux **et** autre majeure dans le même handshake → `-32001` (le token d'abord), puis l'optimiseur ferme. Exigé à partir de 1.3 ; `-32602` d'un optimiseur 1.2 est averti. **SKIP** sans `ETM_POWERSYNC_OPTIMIZER_TOKEN_FILE` : sans token attendu, l'ordre ne s'éprouve pas | | `testTheSessionMatchesTheContractOffUtc` | la fonction 3, le moteur sous un fuseau **non UTC** (`Europe/Berlin`) et cadencé en heure locale comme en production, à travers le passage à l'heure d'hiver (02:58 CEST → 02:01 CET) : aucun échec de requête, session conforme au contrat embarqué (tout instant porte son décalage, §5.2) | -Ces deux dernières lignes valent pour le mode `plan` (défaut). Voir **Optimiseur qui s'abstient**. +Les lignes `testEveryPlanIsAccepted` et `testTheSafetyLoopStaysOnTop` valent pour le mode `plan` (défaut). Voir **Optimiseur qui s'abstient**. -Les fonctions 6 à 9 ne branchent pas le moteur : elles envoient, par une **connexion de sonde**, ce +Les fonctions 6 à 9, 11 et 12 ne branchent pas le moteur : elles envoient, par une **connexion de sonde**, ce que son client n'envoie jamais, et lisent la réponse brute (un lot renvoyé se verrait). Elles utilisent des requêtes dont la réponse est normée sans contexte — `UpdateLearned` avec `id` → `-32601` (§8) — pour constater qu'un message a été, ou n'a pas été, traité. @@ -42,7 +44,8 @@ export ETM_POWERSYNC_OPTIMIZER_CMD='exec /chemin/de/mon-optimiseur --listen {end for fn in testHandshakeIsAccepted testGetPlanIsAnsweredInTime testTheSessionMatchesTheContract \ testEveryPlanIsAccepted testTheSafetyLoopStaysOnTop testGetPlanBeforeTheHandshakeIsRefused \ testARefusedHandshakeIsClosedByTheOptimizer testABatchIsRefused testALineOverTheLimitIsRefused \ - testTheSessionMatchesTheContractOffUtc; do + testTheSessionMatchesTheContractOffUtc testAnInvalidContextIsRefusedWithItsProblems \ + testTheTokenIsCheckedBeforeTheMajor; do systemd-run --user --scope -q -p MemoryMax=2G \ bwrap --dev-bind / / --unshare-net --tmpfs /tmp --bind "$PWD/../../.." "$PWD/../../.." \ --bind --chdir "$PWD" ./target_wrapper.sh ./etm-powersync-optimizer-conformance "$fn" @@ -61,6 +64,19 @@ commande vit dans le même réseau privé (la boucle locale y fonctionne, `tcp:/ | `ETM_POWERSYNC_SOURCE_DIR` | sources du build | Racine où trouver le validateur et l'optimiseur de référence | | `ETM_POWERSYNC_CONFORMANCE_EXPECT` | `plan` | Ce que la suite attend : `plan` (l'optimiseur commande) ou `abstain` (il s'abstient à chaque cycle). Toute autre valeur fait échouer | +**Un optimiseur 1.2 face aux règles 1.3.** Une mineure ajoute des règles (protocole §9) : la suite +lit la version que l'optimiseur annonce au handshake et n'exige la forme d'`error.data` et l'ordre +token puis majeure qu'à partir de 1.3. En deçà, l'écart est écrit en `AVERTISSEMENT` et la fonction +reste verte — l'optimiseur fonctionne avec ce moteur, il s'aligne avant de s'annoncer 1.3. Pour +éprouver l'ordre, donner un token (`ETM_POWERSYNC_OPTIMIZER_TOKEN_FILE`, et le même fichier à +l'optimiseur) : + +```sh +printf 'jeton-de-conformite-0123456789' > /chemin/token +export ETM_POWERSYNC_OPTIMIZER_TOKEN_FILE=/chemin/token +export ETM_POWERSYNC_OPTIMIZER_CMD='exec /chemin/de/mon-optimiseur --listen {endpoint} --token-file /chemin/token' +``` + L'attente de démarrage ouvre une connexion de sonde, refermée sans handshake : un serveur conforme la tolère. Un échec dit ce qui manque — code d'erreur du client, écart au contrat (chemin, mot-clé), cycles non appliqués avec leur `fallbackCode`, latences. @@ -95,7 +111,7 @@ fondée sur ces champs jugerait faux dans les deux cas : le mode se donne, il ne La **qualité** des plans (aucune comparaison au rule-based : c'est `refoptimizer`, pour la référence seulement), les bornes VE (aucune borne au banc), `UpdateLearned` émis par l'optimiseur (ignoré par le moteur ; sa forme n'est pas normée en 1.2), le token envoyé à un optimiseur qui n'en exige pas (§3, 1.2 : accepté ; -le moteur n'envoie de token que si l'installateur en a posé un), la réponse à un contexte `GetPlan` -invalide (§7, 1.2 : `-32602` ; la forme d'`error.data` n'est pas normée), le comportement sous défaut -de transport. Le contrat : `docs/etm-powersync-optimizer-protocol.openrpc.json` +le moteur n'envoie de token que si l'installateur en a posé un), l'exhaustivité de la validation du +contexte par l'optimiseur (un seul contexte invalide est envoyé ; il en suffit d'un problème pour +juger la forme), le comportement sous défaut de transport. Le contrat : `docs/etm-powersync-optimizer-protocol.openrpc.json` (prose : `docs/OPTIMIZER_PROTOCOL.md`, annexe G). diff --git a/tests/auto/conformance/testconformance.cpp b/tests/auto/conformance/testconformance.cpp index 92403de..c829d02 100644 --- a/tests/auto/conformance/testconformance.cpp +++ b/tests/auto/conformance/testconformance.cpp @@ -22,7 +22,11 @@ // autre majeure (-32602 puis fermeture par l'optimiseur), lot (-32600 à id null, rien du lot // traité), ligne de plus de 1 Mio (-32600 à id null, connexion gardée) ; // 10. le point 3, le moteur réglé sur un fuseau NON UTC (Europe/Berlin) et cadencé en heure -// locale comme en production, à travers le passage à l'heure d'hiver. +// locale comme en production, à travers le passage à l'heure d'hiver ; +// 11 et 12 (protocole 1.3), par la sonde : un contexte GetPlan invalide → -32602 dont `error.data` +// a la forme normée, jugée par le validateur publié — exigée d'un optimiseur qui s'annonce 1.3 +// ou plus, avertie pour un 1.2 ; la valeur reçue n'y est jamais recopiée. Token faux ET autre +// majeure dans le même handshake → -32001 (le token d'abord), puis fermeture. // // Mode attendu ($ETM_POWERSYNC_CONFORMANCE_EXPECT, défaut `plan`) : `abstain` juge un optimiseur // qui s'abstient toujours (§7, réponse permise) — le point 4 exige alors une abstention à chaque @@ -420,6 +424,8 @@ private slots: void testABatchIsRefused(); void testALineOverTheLimitIsRefused(); void testTheSessionMatchesTheContractOffUtc(); + void testAnInvalidContextIsRefusedWithItsProblems(); + void testTheTokenIsCheckedBeforeTheMajor(); private: void monter(); @@ -431,8 +437,8 @@ private: void sonder(std::unique_ptr &sonde); //! Un Handshake de sonde en \p version, avec \p token s'il n'est pas vide. QByteArray handshake(int id, const QString &version, const QString &token) const; - //! Handshake accepté sur \p sonde, ou échec du test. - void ouvrirSession(Sonde &sonde, int id); + //! Handshake accepté sur \p sonde, ou échec du test ; \p reponse : le `result` reçu, si demandé. + void ouvrirSession(Sonde &sonde, int id, QJsonObject *reponse = nullptr); //! Lance l'optimiseur et y branche le moteur (directement, ou par le relais). void brancher(bool parLeRelais); void cycle(const QDateTime &t); @@ -846,7 +852,7 @@ QByteArray TestOptimizerConformance::handshake(int id, const QString &version, c .toJson(QJsonDocument::Compact); } -void TestOptimizerConformance::ouvrirSession(Sonde &sonde, int id) +void TestOptimizerConformance::ouvrirSession(Sonde &sonde, int id, QJsonObject *reponse) { sonde.envoyer(handshake(id, QString::fromLatin1(OptimizerProtocol::ProtocolVersion), m_token)); QByteArray ligne; @@ -854,8 +860,33 @@ void TestOptimizerConformance::ouvrirSession(Sonde &sonde, int id) const QJsonObject m = QJsonDocument::fromJson(ligne).object(); QVERIFY2(m.value("id").toInt() == id && m.value("result").toObject().value("protocolVersion").isString(), qUtf8Printable("handshake de sonde refusé : " + QString::fromUtf8(ligne.left(300)))); + if (reponse) + *reponse = m; } +namespace { + +//! « 1.3 » → 103 ; illisible → -1. Seules majeure et mineure comptent (§9). +int versionComparable(const QString &v) +{ + bool okMajeure = false, okMineure = true; + const int majeure = v.section('.', 0, 0).toInt(&okMajeure); + const int mineure = v.contains('.') ? v.section('.', 1, 1).toInt(&okMineure) : 0; + return okMajeure && okMineure ? majeure * 100 + mineure : -1; +} + +//! Version à partir de laquelle les règles 1.3 sont EXIGÉES ; en deçà, un écart est averti. +constexpr int kExigeDepuis = 103; + +//! Vrai si \p annoncee est LISIBLE et antérieure à 1.3 — une version illisible est jugée 1.3. +bool avantRegles13(const QString &annoncee) +{ + const int v = versionComparable(annoncee); + return v >= 0 && v < kExigeDepuis; +} + +} // namespace + void TestOptimizerConformance::testGetPlanBeforeTheHandshakeIsRefused() { // §3 (1.2) : GetPlan avant un handshake réussi → -32600, et la connexion est GARDÉE — le @@ -983,5 +1014,111 @@ void TestOptimizerConformance::testTheSessionMatchesTheContractOffUtc() jugerLaSession(); } +// --- 11 et 12 : protocole 1.3, par une connexion de sonde -------------------------------------------- + +void TestOptimizerConformance::testAnInvalidContextIsRefusedWithItsProblems() +{ + // §7 (1.2) : un contexte GetPlan invalide → -32602, connexion gardée. §7 (1.3) : `error.data` + // = {"problems":[{path, problem}]}, jamais la valeur reçue. La forme est jugée par le VALIDATEUR + // PUBLIÉ, sur la session de sonde (--responses-only : la requête est fautive à dessein) et + // contre le contrat EMBARQUÉ ; c'est lui qui exige ou avertit selon la version que + // l'optimiseur a annoncée. Le contexte porte une valeur qu'aucun chemin conforme ne peut + // contenir : si elle revient dans `error.data`, c'est une recopie. + static const QString marqueur = QStringLiteral("valeur-sonde:7Qz9"); + std::unique_ptr sonde; + sonder(sonde); + if (QTest::currentTestFailed()) + return; + QJsonObject hsReponse; + ouvrirSession(*sonde, 1, &hsReponse); + if (QTest::currentTestFailed()) + return; + const QString annoncee = hsReponse.value("result").toObject().value("protocolVersion").toString(); + const QJsonObject getPlan{{"jsonrpc", "2.0"}, {"id", 2}, {"method", "GetPlan"}, + {"params", QJsonObject{{"timestamp", marqueur}}}}; + sonde->envoyer(QJsonDocument(getPlan).toJson(QJsonDocument::Compact)); + QByteArray ligne; + QVERIFY2(sonde->recevoir(&ligne), "contexte invalide : aucune réponse (ou connexion fermée)"); + const QString e = verifierErreur(ligne, 2, -32602); + QVERIFY2(e.isEmpty(), qUtf8Printable(e)); + const QJsonObject reponse = QJsonDocument::fromJson(ligne).object(); + // Garde indépendante du validateur : la valeur reçue n'est nulle part dans `error.data`. + const QJsonValue data = reponse.value("error").toObject().value("data"); + const QByteArray dataTexte = QJsonDocument(QJsonArray{data}).toJson(QJsonDocument::Compact); + QVERIFY2(!dataTexte.contains(marqueur.toUtf8()), + "error.data recopie la valeur reçue (params.timestamp) — jamais la valeur reçue (§7)"); + + // La session de sonde, jugée par le validateur publié. Le token y devient des astérisques. + QJsonObject hsParams = QJsonDocument::fromJson(handshake(1, QString::fromLatin1(OptimizerProtocol::ProtocolVersion), + m_token)).object(); + if (!m_token.isEmpty()) { + QJsonObject p = hsParams.value("params").toObject(); + p.insert("token", QString(m_token.size(), QLatin1Char('*'))); + hsParams.insert("params", p); + } + QTemporaryDir dir; + QVERIFY(dir.isValid()); + const Contrat::Verdict v = Contrat::juger({hsParams, hsReponse, getPlan, reponse}, + Contrat::ecrireSchema(dir, OptimizerProtocol::schemaDocumentBytes()), + {QStringLiteral("--responses-only")}); + QVERIFY2(v.code == 0, qUtf8Printable(QStringLiteral("optimiseur en %1 — error.data NON CONFORME au contrat :\n") + .arg(annoncee.left(16)) + v.sortie.right(4000))); + // Le contrôle a bien eu lieu : un validateur qui ne trouverait plus la forme dans le contrat + // passerait au vert sans rien juger. + QVERIFY2(v.compte("GetPlan.error.data") == 1, qUtf8Printable(v.sortie.right(1000))); + const int avertissements = v.resume.value("avertissements").toInt(); + for (const QString &l : v.sortie.split('\n')) + if (l.startsWith(QStringLiteral("AVERTISSEMENT"))) + qWarning().noquote() << "[Conformité]" << l; + // Un avertissement n'est permis qu'à un optimiseur d'avant 1.3 : au-delà, c'est un écart. + QVERIFY2(avertissements == 0 || avantRegles13(annoncee), qUtf8Printable(v.sortie.right(1000))); + qInfo().noquote() << "[Conformité] contexte invalide : -32602, optimiseur en" << annoncee + << (avertissements ? QStringLiteral("— forme d'error.data AVERTIE (%1), à aligner avant 1.3").arg(avertissements) + : QStringLiteral("— forme d'error.data conforme")); + // Connexion gardée : la sonde peut encore parler. + sonde->envoyer(R"({"jsonrpc":"2.0","id":3,"method":"UpdateLearned","params":{}})"); + QVERIFY2(sonde->recevoir(&ligne), "après le contexte invalide : aucune réponse (ou connexion fermée)"); + const QString e2 = verifierErreur(ligne, 3, -32601); + QVERIFY2(e2.isEmpty(), qUtf8Printable(e2)); +} + +void TestOptimizerConformance::testTheTokenIsCheckedBeforeTheMajor() +{ + // §3 (1.3) : token faux ET autre majeure dans le même handshake → -32001, le token d'abord, + // puis l'optimiseur ferme. Exigé d'un optimiseur qui s'annonce 1.3 ou plus ; un optimiseur 1.2 + // qui juge la majeure d'abord (-32602) est averti. Sans token attendu, rien à éprouver. + std::unique_ptr sonde; + sonder(sonde); + if (QTest::currentTestFailed()) + return; + if (m_token.isEmpty()) + QSKIP("aucun token attendu (ETM_POWERSYNC_OPTIMIZER_TOKEN_FILE) : ordre token puis majeure non éprouvé"); + QJsonObject hsReponse; + ouvrirSession(*sonde, 1, &hsReponse); // la version annoncée, sur une session acceptée + if (QTest::currentTestFailed()) + return; + const QString annoncee = hsReponse.value("result").toObject().value("protocolVersion").toString(); + const QString autreMajeure = QString::number(QString::fromLatin1(OptimizerProtocol::ProtocolVersion).section('.', 0, 0).toInt() + 1) + ".0"; + Sonde faux(endpoint()); + QVERIFY(faux.ouverte()); + faux.envoyer(handshake(1, autreMajeure, m_token + QStringLiteral("-faux"))); + QByteArray ligne; + QVERIFY2(faux.recevoir(&ligne), "token faux et autre majeure : aucune réponse avant la fermeture"); + const QString e = verifierErreur(ligne, 1, -32001); + if (!e.isEmpty()) { + // La majeure jugée d'abord : -32602 (règle 1.2), un écart d'ordre seulement. + const bool majeureDabord = verifierErreur(ligne, 1, -32602).isEmpty(); + QVERIFY2(majeureDabord && avantRegles13(annoncee), + qUtf8Printable(QStringLiteral("optimiseur en %1 : ").arg(annoncee.left(16)) + e)); + qWarning().noquote() << "[Conformité] AVERTISSEMENT optimiseur en" << annoncee.left(16) + << ": token faux et autre majeure → -32602, la majeure jugée avant le token —" + " -32001 exigé à partir de 1.3, à aligner avant de s'annoncer 1.3"; + } else { + qInfo().noquote() << "[Conformité] token faux et autre majeure : -32001, le token d'abord (optimiseur en" + << annoncee.left(16) + ")"; + } + QVERIFY2(faux.fermeeParLautre(5000), "handshake refusé : l'optimiseur n'a pas fermé la connexion en 5 s"); +} + QTEST_MAIN(TestOptimizerConformance) #include "testconformance.moc"