PD-286 / C8 — Livrable agent app-volume-verifier
Agent : agent-developer (Claude) Module : app-volume-verifier Projet : ProbatioVault-app (React Native + Expo SDK 54 + TypeScript) Référence plan : PD-286-plan.md §1.2 C8, §3 (INV-286-07), §5 (TC-NOM-04 / TC-ERR-04) Référence contract : PD-286-code-contracts.yaml module app-volume-verifier
1. Synthèse
Implémentation du vérificateur d'intégrité de volume utilisé par l'orchestrator app dans le flux multi-volumes (INV-286-07) :
- recompute =
SHA3-256(JCS_RFC8785(manifest \ {integrityHash})) - comparaison stricte
recomputed === expected (case-sensitive) - en cas de mismatch :
HashMismatchException synchronique avec uniquement les 8 premiers chars de chaque hash - aucune mutation du manifest reçu (clone défensif JSON)
- aucune dépendance npm
canonicalize ni alternative — réutilisation de la lib RFC 8785 interne PD-54 (src/crypto/merkle-tree/canonicalizer)
2. Fichiers livrés
| Fichier | Lignes | Rôle |
src/export/volume-verifier.ts | 130 | API publique : verifyVolume, defaultVolumeVerifier, HashMismatchException, VolumeVerificationInput, VolumeVerificationOk, HashMismatchDetail, VolumeVerifier |
src/export/__tests__/volume-verifier.test.ts | 245 | 14 tests, couvrent TC-NOM-04 + TC-ERR-04 + invariants forbidden |
Aucun fichier hors périmètre n'a été modifié.
3.1 Interfaces (contract interfaces)
| Contract | Implémenté | Localisation |
VolumeVerifier | Interface TS exportée + defaultVolumeVerifier (impl par défaut) | volume-verifier.ts:67-69 + 131-133 |
HashMismatchException | class HashMismatchException extends Error | volume-verifier.ts:43-58 |
3.2 Invariants (contract invariants)
| Invariant contract | Mécanisme | Test |
recompute = SHA3-256(JCS(manifest \ {integrityHash})) ; comparaison stricte (===) | verifyVolume() : clone → delete integrityHash → canonicalizeOrThrow → hashBytes → === | TC-NOM-04 (4 cas) |
| Lib JCS partagée interne uniquement | Import direct ../crypto/merkle-tree/canonicalizer (PD-54, RFC 8785) | — (vérifié à l'inspection imports) |
| Lib SHA3-256 audité | Réutilisation streaming-hasher.hashBytes qui s'appuie sur @noble/hashes/sha3 | — |
| Mismatch → throw avec uniquement 8 premiers chars | HashMismatchException({volumeIndex, expectedPrefix, recomputedPrefix}) ; slice(0, 8) ; aucune occurrence du hash complet dans message | « exposes only the 8-char prefix » |
| Réutilise streaming-hasher.ts | hashBytes(canonicalBytes) depuis ./streaming-hasher | — |
3.3 Forbidden (contract forbidden)
| Interdiction | Vérification |
| Comparaison case-insensitive | Test « rejects an uppercased version of the correct hash » (un hash valide en majuscules est explicitement rejeté) |
| Continuation après mismatch | throw synchronique en plein milieu de verifyVolume, aucun try/catch interne ; testé par « throws synchronously (no orphan promise) » |
| Log du hash complet | HashMismatchException.message ne contient jamais le hash complet : assertion expect(ex.message).not.toContain(expectedHash) |
Import npm canonicalize ou alternative | Aucun import de canonicalize, json-canonicalize, etc. ; seule la lib interne PD-54 est utilisée |
| Vérification asynchrone non awaited | verifyVolume est synchrone (retourne VolumeVerificationOk, pas Promise<...>) |
| Mutation du manifest reçu | JSON.parse(JSON.stringify(manifest)) → clone profond ; testé par 2 tests (« does not mutate the manifest object passed in » + « does not mutate nested arrays/objects either ») |
Note sur le clone défensif : le contrat citait structuredClone comme exemple, mais cette API n'est pas disponible dans l'environnement Jest utilisé par le projet (et incertaine sur tous les builds Hermes). Le manifest reçu est garanti JSON-sérialisable (validé par Zod en amont via multiVolumeExportApiResponseSchema), donc JSON.parse(JSON.stringify(...)) est strictement équivalent et portable. La règle métier — « pas de mutation » — est respectée et testée.
3.4 Fichiers (contract files)
| Contract | Livré |
src/export/volume-verifier.ts | ✅ |
src/export/__tests__/volume-verifier.test.ts | ✅ |
4. Couverture des critères et tests du plan
4.1 Critères d'acceptation couverts (PD-286-tests.md)
| ID | Critère | Mécanisme dans C8 |
| CA-286-04 | Hash de chaque volume vérifié via manifest API | verifyVolume() est l'unique point de vérification, appelé volume par volume par l'orchestrator |
| CA-286-05 | Échec d'un volume → échec export global | HashMismatchException levée synchroniquement → l'orchestrator (C7) doit la propager vers state FAILED |
4.2 Tests couverts
| Test ID plan | Implémenté ici | Cas |
| TC-NOM-04 | ✅ | « returns the recomputed hash when manifest matches » + « works when manifest contains nested objects and arrays » + « supports Unicode payloads » + « is deterministic regardless of integrityHash position » |
| TC-ERR-04 | ✅ | « throws when manifest content differs » + « throws when expectedIntegrityHash is forged » + « exposes only the 8-char prefix » + « throws synchronously » |
Tests additionnels qualité :
strict case-sensitive comparison (1 cas) — protège INV-286-07 contre régression future qui ajouterait toLowerCase() defensive cloning of input manifest (2 cas) — vérifie que ni les clés du manifest ni les sous-objets ne sont mutés determinism (1 cas) — appels répétés produisent le même hash defaultVolumeVerifier façade (2 cas) — vérifie que la façade DI reflète exactement le comportement de verifyVolume
5. Décisions architecturales
Format architectural_decisions du code contract — max 1-2 décisions, focalisées sur des choix non triviaux.
architectural_decisions:
- decision: |
API synchrone (`verifyVolume(input): VolumeVerificationOk`) plutôt
qu'asynchrone (`async`).
rationale: |
Le manifest est en mémoire (< 1 MB par contrat), la canonicalisation
JCS et SHA3-256 sont CPU-bound. Une signature sync élimine par
construction le risque d'orphan promise (forbidden) et fait apparaître
le throw `HashMismatchException` directement dans le call stack de
l'orchestrator, simplifiant l'enforcement de l'arrêt immédiat.
alternatives_considered:
- "API async + Promise.reject — rejetée : risque orphan promise + signature non alignée avec la nature CPU-bound"
- "Callback (err, ok) — rejetée : style non idiomatique du reste du code export"
trade_offs:
- "+ Throw synchronique inarrêtable, alignement direct avec INV-286-07"
- "+ Stack trace simple pour debug"
- "- Si la canonicalisation devenait coûteuse (manifest > 1MB), bloquerait le thread JS — mitigation : assertion taille au boundary upstream (orchestrator/api-client)"
- decision: |
Réutilisation directe de `canonicalizeOrThrow` (PD-54,
`src/crypto/merkle-tree/canonicalizer`) plutôt qu'un nouveau package
`@probatiovault/jcs`.
rationale: |
Le contract H-PD286-PLAN-01 nomme `@probatiovault/jcs` mais aucun
package npm interne n'existe à ce nom. Le canonicalizer PD-54 EST
déjà la lib JCS interne du monorepo, RFC 8785-conforme, audité,
utilisé par le moteur Merkle. La créer en doublon serait du copy-paste
; la promotion en package interne est une dette à traiter par une
story dédiée (cf. §6).
alternatives_considered:
- "Créer `packages/jcs` cross-projet — hors scope module C8, demande coordination backend (le manifest doit utiliser strictement la même implémentation côté backend C3)"
- "Inliner une copie locale dans `src/export/` — rejeté : duplication de code crypto critique"
trade_offs:
- "+ Zéro duplication, lib auditée"
- "+ Implémentation byte-identical garantie au sein du projet app"
- "- Couplage `src/export/` ↔ `src/crypto/merkle-tree/` ; à factoriser le jour où un package interne sera créé"
- "- Le typage `Event` de l'API publique force un cast `as unknown as Event` (le runtime `serializeValue` est totalement générique, le cast est inoffensif)"
6. Hypothèses confirmées et points de vigilance
6.1 Hypothèses du plan validées
- H-PD286-PLAN-01 (JCS cross-runtime) : confirmé par tests Unicode et structures imbriquées. Le canonicalizer PD-54 produit un résultat déterministe sur le même input (test « determinism »). La validation byte-identical avec la canonicalisation backend reste à confirmer côté CI roundtrip (responsabilité agent C3 + suite intégration plus large).
6.2 Points de vigilance pour l'orchestrator (C7)
L'orchestrator app DOIT respecter les invariants suivants pour ne pas dégrader la garantie INV-286-07 :
- Appeler
verify() AVANT pvproof-assembler.appendVolume() — pas après, pas en parallèle. C'est le point d'observation clé de TC-NOM-04 (« log volume_hash_verified{volumeIndex} AVANT volume_appended »). - Catcher
HashMismatchException explicitement et transitionner vers state FAILED puis abort. Ne JAMAIS continuer le traitement des volumes restants après mismatch (forbidden : continuation après mismatch). - Ne JAMAIS logger l'objet
HashMismatchException en raw via console.error(ex) sans veiller à n'inclure que expectedPrefix/recomputedPrefix — la classe garantit déjà que message n'expose que les préfixes, mais des sérialiseurs JSON (Sentry, Bugsnag) peuvent capturer toutes les propriétés énumérables, ce qui est acceptable ici (les propriétés exposées sont explicitement les préfixes).
6.3 Dette technique laissée en l'état
| Sujet | Description | Action recommandée |
| Lib JCS partagée | @probatiovault/jcs n'existe pas comme package npm interne. Le canonicalizer PD-54 fait office de lib interne mais est typé Event. | Story dédiée pour extraire un package packages/jcs (cross-projet backend + app) avec API générique canonicalizeJson(value: unknown): Uint8Array. Tant que cette story n'est pas faite, le cast as unknown as Event reste nécessaire dans volume-verifier.ts:115. |
7. Vérification locale
| Vérification | Commande | Résultat |
| Type-check sur les 2 fichiers livrés | npx tsc --noEmit 2>&1 \| grep volume-verifier | 0 erreur |
| Tests unitaires C8 | npx jest src/export/__tests__/volume-verifier.test.ts | 14/14 passés (557 ms) |
Note : tsc --noEmit global remonte des erreurs préexistantes dans d'autres modules (src/__tests__/AppNavigator.test.tsx, src/services/biometricKeychain.qwen.test.ts, src/crypto/aes-gcm.ts, etc.). Aucune n'est imputable à C8 — elles préexistaient à mon travail et sortent du périmètre du contract app-volume-verifier.
8. Hors périmètre (signalé, non modifié)
- Intégration dans l'orchestrator (C7) : l'orchestrator appellera
defaultVolumeVerifier.verify(...) ; non modifié ici (responsabilité de l'agent app-export-orchestrator-multi). - Audit côté app sur mismatch :
gov_signal_* ou audit local, à brancher dans C7. - Création d'un package
packages/jcs : décrit en §6.3 comme dette à traiter par une story dédiée.