PD-286 — Plan d'implémentation
Story : Export probatoire multi-volumes (au-delà de 768MB, jusqu'à 10GB) Stack : ProbatioVault-backend (NestJS + TypeORM + PostgreSQL) + ProbatioVault-app (React Native + Expo SDK 54 + TypeScript) Baseline : Étend l'export PD-85 (src/modules/export/) avec partitionnement en volumes — conserve la rétrocompatibilité legacy single-volume (INV-286-05). Gate 3 v2 : GO (verdict-step3-v2.yaml). Les ambiguïtés résiduelles (canonicalisation cross-runtime, source d'horloge TTL, taille totale max calculée vs déclarée) sont reportées en §8 (Hypothèses) et §9 (Vigilance).
1. Découpage en composants
1.1 Backend (ProbatioVault-backend/src/modules/export/)
| C | Module | Responsabilité | Fichiers cibles principaux |
| C1 | export-partitioner | Algorithme de partitionnement déterministe : répartit les preuves validées en volumes respectant INV-286-01 (standard ≤ 805_306_368 B, dédié exceptionnel ≤ 10_737_418_240 B). Renvoie Volume[] ou rejet métier. | services/export-partitioner.service.ts, services/__tests__/export-partitioner.service.spec.ts |
| C2 | export-multi-dto | DTO ExportVolumeDto, extension ComplaintFileResponseDto (champs volumes[], totalVolumes, manifestRootHash), validation class-validator. Conserve l'ancienne forme single-volume. | dto/export-volume.dto.ts, dto/complaint-file-response.dto.ts (extension), dto/index.ts |
| C3 | export-manifest-multi | Construit le manifest racine multi-volumes + un manifest partiel par volume. Calcule integrityHash partiel via IntegrityHashComputer existant (PD-85). Calcule manifestRootHash à partir des hashes partiels. | services/export-manifest-builder.service.ts (extension), services/manifest-root-builder.service.ts |
| C4 | export-state-machine-backend | Implémente la matrice §4 spec côté backend pour les transitions amont (REQUESTED → PLANNED_SINGLE | PLANNED_MULTI | FAILED). Refuse toute transition non listée. Persistance via colonne export_state enum PostgreSQL. | state/export-state.machine.ts, entities/export-session.entity.ts, migrations/<ts>-pd286-export-multi-volumes.ts |
| C5 | export-controller-multi | Extension de ComplaintFileController (PD-85) : aiguillage single-volume legacy vs volumes[] selon totalSize et présence d'une preuve > VOLUME_MAX_BYTES. Rejets HTTP 413 métier (ERR-286-01/02), 400/422 (ERR-286-08). Renvoie réponse 200 conforme. | controllers/complaint-file.controller.ts (extension), services/export.service.ts (extension) |
| C6 | export-audit-multi | Émet l'événement WORM avec exportId, volumes_count, integrityHash[], statut final. Append synchrone dans la transaction (anti-catch-absorb learning 2026-03-08). | services/export-audit.service.ts (nouveau, façade sur audit-journal existant) |
1.2 App (ProbatioVault-app/src/export/)
| C | Module | Responsabilité | Fichiers cibles principaux |
| C7 | app-export-orchestrator-multi | Étend orchestrator.ts (PD-85) : détecte présence de volumes[], lance traitement séquentiel volume par volume, expose progression unique (poids = somme estimatedBytes). | orchestrator.ts (extension), state-machine.ts (extension transitions §4) |
| C8 | app-volume-verifier | Pour chaque volume reçu : recalcul SHA3-256(jcs_rfc8785(manifest)), comparaison stricte avec integrityHash attendu (case-sensitive, regex /^[0-9a-f]{64}$/). Échec → FAILED global, audit local. | volume-verifier.ts (nouveau), réutilise streaming-hasher.ts + crypto-pipeline.ts |
| C9 | app-pvproof-assembler-multi | Étend pvproof-assembler.ts : si volumes[], génère .pvproof UNIQUE avec pvproof.json interne enrichi (volumes_count, assembled_from[] cardinalité = totalVolumes). Conteneur format inchangé. | pvproof-assembler.ts (extension) |
| C10 | app-export-api-client | Étend api-client.ts : typage ExportVolumeDto, validation Zod côté client (regex integrityHash, bornes volumeIndex/totalVolumes, format signedUrl HTTPS ≤ 4096 chars). | api-client.ts (extension), schemas.ts (extension Zod) |
1.3 Tests (backend/test/ et app/src/__tests__/)
| C | Module | Responsabilité |
| C11 | tests | Suite complète : unit (partitioner, hash, state machine), intégration (controller + service + DB réelle, app pipeline complet avec backend stubé), E2E (export 500MB single + 2GB multi via env staging). |
2. Flux techniques
2.1 Flux nominal A — single-volume legacy (totalSize ≤ 805_306_368)
Client → POST /exports/complaint-file {complaintId}
Backend ExportService.export()
├── validate ownership (existant PD-85)
├── compute totalSize = Σ proof.bytes
├── if totalSize ≤ VOLUME_MAX_BYTES AND ∀ proof : proof.bytes ≤ VOLUME_MAX_BYTES
│ → ExportStateMachine.transition(REQUESTED → PLANNED_SINGLE)
│ → ExportManifestBuilder.build() (PD-85 inchangé)
│ → réponse legacy : {exportId, manifest, signedUrls[], chronology, ...}
└── ExportAudit.appendStart(exportId, volumes_count=1)
App orchestrator
├── détecte absence de `volumes[]` → branche legacy PD-85
├── télécharge signedUrls, vérifie hash (existant)
└── pvproof-assembler.assemble() → .pvproof unique
2.2 Flux nominal B — multi-volumes (totalSize > 805_306_368 OU preuve atomique > 805_306_368)
Backend ExportService.export()
├── validate ownership
├── compute totalSize
├── if totalSize > MAX_TOTAL_EXPORT_BYTES → throw 413 EXPORT_TOTAL_LIMIT_EXCEEDED
├── if ∃ proof : proof.bytes > MAX_TOTAL_EXPORT_BYTES → throw 413 PROOF_TOO_LARGE
├── ExportPartitioner.partition(proofs) → Volume[]
│ - tri preuves par taille décroissante (déterministe)
│ - pour chaque preuve : si proof.bytes > VOLUME_MAX_BYTES → volume dédié exceptionnel (1 preuve)
│ - sinon : ajoute au volume courant si Σ ≤ VOLUME_MAX_BYTES, sinon ouvre nouveau volume
│ - assigne volumeIndex 0..N-1 sans trou ni doublon (INV-286-06)
├── ExportStateMachine.transition(REQUESTED → PLANNED_MULTI)
├── pour chaque volume v :
│ - manifest_partial = build(volume.proofs)
│ - v.integrityHash = SHA3-256(JCS(manifest_partial \ {integrityHash}))
│ - v.signedUrl = StorageSigner.sign(file_v, ttl=SIGNED_URL_TTL)
│ - v.manifest = manifest_partial (avec integrityHash)
├── manifestRootHash = SHA3-256(JCS({volumes: [{volumeIndex, integrityHash, estimatedBytes}], totalVolumes, exportId}))
├── ExportAudit.appendStart(exportId, volumes_count=N, integrityHash[])
└── return ComplaintFileResponseDto {exportId, totalVolumes, volumes: [ExportVolumeDto], manifestRootHash, ...legacy fields nullables}
App orchestrator
├── détecte volumes[] → state DOWNLOADING
├── tri local par volumeIndex ascendant (déterministe quel que soit l'ordre serveur — voir §9)
├── for v in volumes (séquentiel, abort sur first failure) :
│ ├── api-client.fetchVolume(v.signedUrl) → bytes
│ ├── volume-verifier.verify(bytes, v.manifest, v.integrityHash) :
│ │ - recompute = SHA3-256(JCS(v.manifest \ {integrityHash}))
│ │ - if recompute !== v.integrityHash → throw HashMismatch → state FAILED
│ ├── pvproof-assembler.appendVolume(bytes, v.manifest)
├── state DOWNLOADING → ASSEMBLING
├── pvproof-assembler.finalize(volumes_count=N, assembled_from=volumes.map(...))
├── state ASSEMBLING → COMPLETED
└── ExportAudit (côté serveur via callback) : appendEnd(exportId, status=COMPLETED)
2bis. Diagramme de dépendances agents (step 6b)
graph LR
C1[C1 export-partitioner]
C2[C2 export-multi-dto]
C3[C3 export-manifest-multi]
C4[C4 export-state-machine-backend]
C5[C5 export-controller-multi]
C6[C6 export-audit-multi]
C7[C7 app-export-orchestrator-multi]
C8[C8 app-volume-verifier]
C9[C9 app-pvproof-assembler-multi]
C10[C10 app-export-api-client]
C11[C11 tests]
C2 --> C5
C2 --> C10
C1 --> C5
C3 --> C5
C4 --> C5
C5 --> C6
C10 --> C7
C8 --> C7
C9 --> C7
C5 --> C11
C6 --> C11
C7 --> C11
subgraph Wave1 ["Wave 1 — Foundations (parallèle)"]
C2
C4
end
subgraph Wave2 ["Wave 2 — Backend services (parallèle)"]
C1
C3
C10
C8
C9
end
subgraph Wave3 ["Wave 3 — Intégration"]
C5
C7
end
subgraph Wave4 ["Wave 4 — Audit + Tests"]
C6
C11
end
Cohérence avec PD-286-code-contracts.yaml : un nœud = un module YAML. Les waves sont reprises dans dependencies_graph.waves.
2ter. Vérification couverture du diagramme d'état (§5bis spec)
| Transition spec §4 | Mécanisme technique | Composant |
REQUESTED → PLANNED_SINGLE | ExportStateMachine.transition() après partition triviale | C4 |
REQUESTED → PLANNED_MULTI | ExportStateMachine.transition() après ExportPartitioner.partition() retournant N≥1 avec volumes[] requis | C1+C4 |
REQUESTED → FAILED | catch validation backend (413, 422) avant émission volume | C5 |
PLANNED_SINGLE → DOWNLOADING | App state-machine.ts après réception réponse | C7 |
PLANNED_MULTI → DOWNLOADING | idem | C7 |
DOWNLOADING → ASSEMBLING | App orchestrator après dernier volume-verifier.verify() OK | C7+C8 |
DOWNLOADING → FAILED | App orchestrator sur HashMismatch ou erreur réseau | C7+C8 |
ASSEMBLING → COMPLETED | App pvproof-assembler.finalize() succès | C9 |
ASSEMBLING → FAILED | catch finalisation (I/O, intégrité finale) | C9 |
* → EXPIRED | TTL SIGNED_URL_TTL ou EXPORT_SESSION_TTL dépassé (cron worker backend + watchdog app) | C4+C7 |
| Transitions interdites (terminales, downgrade) | ExportStateMachine.canTransition() retourne false + trigger PG prevent_forbidden_export_transition | C4 |
3. Mapping invariants → mécanismes
| Invariant | Exigence | Mécanisme | Composant | Observable | Risque |
| INV-286-01 | 0 < v.estimatedBytes ≤ 805_306_368 OU (≤ 10_737_418_240 ET 1 seule preuve) | ExportPartitioner.partition() borne par constante VOLUME_MAX_BYTES; assertion runtime sur chaque Volume produit; rejet 5xx interne INVALID_VOLUME_SIZE si hors borne | C1 | Test unitaire bornes (1B, exactement 805_306_368, 805_306_369, 10GB, 10GB+1B) ; log volume_built avec estimatedBytes | Régression silencieuse si constante modifiée à la main → couvert par test d'invariant code |
| INV-286-02 | Σ totalSize ≤ 10_737_418_240 B | Check explicite avant partition dans ExportService.export() ; throw 413 EXPORT_TOTAL_LIMIT_EXCEEDED | C5 | TC-ERR-01 ; métrique export_rejected_total{reason="total_limit"} | Calcul totalSize désynchronisé de la taille réelle des fichiers → mitigation : totalSize calculé à partir des metadata DB validées (PD-85) |
| INV-286-03 | Union des preuves = ensemble validé, sans omission/doublon | ExportPartitioner.partition() itère exactement une fois sur chaque preuve ; assertion finale set(union(volumes.proofs)) === set(input) | C1 | Test unitaire TC-INV-03 ; log partition_summary avec liste IDs preuves par volume | Bug d'algorithme (preuve oubliée/doublée) → couvert par invariant runtime + test |
| INV-286-04 | Atomicité preuve | ExportPartitioner ne scinde jamais ; chaque preuve appartient à exactement 1 volume | C1 | Test TC-NOM-06 (preuve 900MB → 1 volume dédié) ; log par preuve | Assemblage erroné si bytes scindés → C9 vérifie correspondance pvproof.json.assembled_from[].proofs ⊆ manifest |
| INV-286-05 | Rétrocompatibilité legacy totalSize ≤ 805_306_368 | ExportService.export() branche : si single-volume éligible → réponse legacy sans volumes[] ; champs volumes/totalVolumes/manifestRootHash non sérialisés (@Exclude ou conditionnels) | C2+C5 | TC-NR-01, snapshot test 200 réponse 500MB ; consommateur PD-85 inchangé | Sérialisation par défaut inclut null → mitigation : class-transformer @Expose({groups: ['multi']}) avec activation conditionnelle |
| INV-286-06 | volumeIndex continu [0..N-1] | ExportPartitioner assigne volumeIndex = i dans la boucle ; assertion forall i in [0,N), exists v with volumeIndex=i and unique | C1+C2 | TC-ERR-06 ; validation Zod côté app (C10) refuse trous/doublons | Index dupliqué silencieux → assertion serveur + double validation client |
| INV-286-07 | Vérification fonctionnelle hash app via manifest reçu | volume-verifier.verify(bytes, manifest, expectedHash) : recompute SHA3-256(JCS(manifest integrityHash)) puis comparaison stricte (Buffer.timingSafeEqual non requis car non secret) | C8 | TC-NOM-04, TC-ERR-04 ; log volume_hash_verified ou volume_hash_mismatch avec volumeIndex et 8 premiers chars | RFC 8785 cross-runtime divergent → cf. H-286-01 et tests roundtrip CI dédiés |
| INV-286-08 | .pvproof unique, conteneur inchangé, pvproof.json enrichi | pvproof-assembler.finalize() : un seul fichier en sortie ; ajoute volumes_count et assembled_from[] (cardinalité = totalVolumes) UNIQUEMENT si volumes[] présent ; format conteneur inchangé (PD-85 baseline) | C9 | TC-NOM-03 ; test snapshot pvproof.json single-volume vs multi-volumes | Consommateur legacy intolérant aux nouveaux champs → cf. H-286-04 et test TC-NR-02 |
| INV-286-09 | Audit WORM fail-closed : exportId, volumes_count, integrityHash[], statut final | ExportAuditService.appendStart/appendEnd : append synchrone dans la transaction Postgres ; rollback si append throw (anti-catch-absorb learning 2026-03-08) | C6 | TC-NOM-05 ; entrée WORM corrélée par exportId ; métrique export_audit_failed_total | Indispo audit → fail-closed (export FAILED) — cf. §9 dette opérationnelle |
| INV-286-10 | Toute transition non listée interdite | ExportStateMachine.canTransition(from,to) → matrice statique ; trigger PG prevent_forbidden_export_transition (defense in depth) ; throw ForbiddenStateTransitionException | C4 | TC-INV-10 ; log state_transition_rejected{from,to} | Drift entre code et trigger → test d'intégration vérifie que toute transition autorisée par code passe le trigger et inversement |
| INV-286-11 | États terminaux COMPLETED/FAILED/EXPIRED sans transition sortante | ExportStateMachine.canTransition() : si from ∈ TERMINAL → toujours false ; trigger PG idem | C4 | TC-INV-11 ; tests transition forcée depuis chaque terminal vers chaque non-terminal | Confusion avec « reprise » → spec §4 explicite « nouvelle requête obligatoire » ; doc dev |
4. Mapping critères d'acceptation → mécanismes
| Critère | Mécanisme(s) | Composant | Observable | Risque |
| CA-286-01 (export 2GB → multi-volumes conforme) | ExportPartitioner.partition() + ExportStateMachine.transition(REQUESTED→PLANNED_MULTI) + sérialisation volumes[] | C1+C4+C2+C5 | TC-NOM-02 ; réponse contient totalVolumes≥2, chaque volume conforme INV-286-01 | Borne 2GB pile : N=3 (3×768MB > 2GB) — couvert par test paramétré |
| CA-286-02 (export 500MB single legacy) | Aiguillage ExportService.export() ; sérialisation @Expose conditionnelle | C5+C2 | TC-NR-01 ; snapshot byte-stable de la réponse legacy | Refactor PD-85 cassant — mitigation : régression CI sur PD-85 + PD-286 |
| CA-286-03 (preuve 900MB → volume dédié) | ExportPartitioner.partition() détecte proof.bytes > VOLUME_MAX_BYTES, ouvre volume dédié unique | C1 | TC-NOM-06 ; log volume_dedicated_exceptional | Confusion volume dédié vs rejet — test paramétré aux bornes 805_306_368/805_306_369/900MB/10GB |
CA-286-04 (.pvproof unique multi-volumes) | pvproof-assembler.finalize() produit 1 fichier ; ajout métadonnées | C9 | TC-NOM-03 ; vérification taille = somme volumes + métadonnées | Concaténation incorrecte — checksum global testé |
| CA-286-05 (hash vérifié côté app) | volume-verifier.verify() appelé avant pvproof-assembler.appendVolume() ; ordre garanti par orchestrator | C8+C7 | TC-NOM-04 ; log explicite volume_hash_verified{volumeIndex} AVANT volume_appended | Bypass possible — test dédié vérifiant l'ordre des appels (spy) |
| CA-286-06 (échec volume → échec global) | volume-verifier.verify() throw → orchestrator catch → state.transition(DOWNLOADING→FAILED) + abort | C7+C8 | TC-ERR-04, TC-ERR-07 ; aucun fichier .pvproof produit | Succès partiel silencieux — test négatif explicite |
CA-286-07 (audit volumes_count + hashes) | ExportAuditService.appendStart(exportId, volumes_count, integrityHash[]) synchrone dans la transaction | C6 | TC-NOM-05 ; query SQL sur table audit WHERE export_id=? retourne entrée avec champs requis | Append asynchrone post-response — interdit (forbidden YAML) |
| CA-286-08 (machine d'états refuse transitions) | ExportStateMachine.canTransition() + trigger PG | C4 | TC-INV-10, TC-NEG-06, TC-NEG-07 ; exception ForbiddenStateTransitionException mappée 500 | Trigger absent en migration — vérifié par test d'intégration migration |
| CA-286-09 (TTL → EXPIRED, reprise interdite) | Worker cron backend (toutes 30s) WHERE state IN (non-terminaux) AND expires_at < now() ; watchdog app sur expires_at ; canTransition(EXPIRED → *) = false sauf [*] | C4+C7 | TC-ERR-05 ; transition * → EXPIRED loggée ; tentative de réutilisation exportId EXPIRED rejetée par état | Décalage horloge app/backend — cf. §9 |
| CA-286-10 (preuve > 10GB → 413 PROOF_TOO_LARGE) | Check proof.bytes > MAX_TOTAL_EXPORT_BYTES avant appel ExportPartitioner ; throw 413 | C5 | TC-ERR-09 ; aucun volume émis | Calcul taille fichier inexact — cf. INV-286-02 |
5. Mapping tests → mécanismes + observables
| Test ID | Référence spec | Mécanisme(s) | Point(s) d'observation | Niveau |
| TC-NOM-01 | INV-286-05, CA-286-02 | C5 aiguillage legacy + C2 sérialisation conditionnelle | Réponse JSON sans volumes[] ; .pvproof unique généré | Integration (backend+app) |
| TC-NOM-02 | INV-286-01/06, CA-286-01 | C1 partition + C2 DTO + C5 réponse | totalVolumes>=2, volumes[i].volumeIndex==i, 0 < estimatedBytes ≤ 805_306_368 | Unit (C1) + Integration (C5) |
| TC-NOM-03 | INV-286-08, CA-286-04 | C9 finalize | .pvproof unique avec pvproof.json.volumes_count==N et assembled_from[].length==N | Integration (C9) |
| TC-NOM-04 | INV-286-07, CA-286-05 | C8 verify + C7 ordre des appels | Log volume_hash_verified{volumeIndex,recomputed,expected} ; spy ordonne verify avant appendVolume | Unit (C8) + Integration (C7) |
| TC-NOM-05 | INV-286-09, CA-286-07 | C6 appendStart/appendEnd | Row WORM avec export_id, volumes_count, integrity_hashes JSONB, status=COMPLETED | Integration (DB réelle) |
| TC-NOM-06 | INV-286-01/04, CA-286-03 | C1 partition (volume dédié) | volumes[].length==1, volumes[0].proofs.length==1, estimatedBytes==900MB | Unit (C1) |
| TC-ERR-01 | ERR-286-01, INV-286-02 | C5 check totalSize | HTTP 413, body {code:"EXPORT_TOTAL_LIMIT_EXCEEDED"}, aucune row export | Integration (HTTP) |
| TC-ERR-03 | ERR-286-03 | C10 validation Zod côté app + C2 validation class-validator côté backend | Réponse rejetée à la frontière, état FAILED, audit fail-closed | Unit (Zod) + Integration |
| TC-ERR-04 | ERR-286-04, INV-286-07 | C8 verify + C7 abort | HashMismatchException ; aucun volume_appended après mismatch | Unit (C8) + Integration |
| TC-ERR-05 | ERR-286-05, CA-286-09 | C4 worker EXPIRED + C7 watchdog | Transition * → EXPIRED ; tentative reprise → état terminal, refus | Integration (avec time-mocking) |
| TC-ERR-06 | ERR-286-06, INV-286-06 | C10 validation Zod ; C1 invariant runtime | Erreur structure, état FAILED | Unit (C10) |
| TC-ERR-07 | ERR-286-07 | C7 abort sur erreur réseau | État FAILED ; aucun succès partiel | Integration (mock réseau) |
| TC-ERR-08 | ERR-286-08 | class-validator (exportId UUID v4 strict, signedUrl URL HTTPS, length<=4096) | HTTP 400/422 selon champ, body {code, field} | Unit |
| TC-ERR-09 | ERR-286-02, CA-286-10 | C5 check preuve > MAX_TOTAL | HTTP 413 PROOF_TOO_LARGE | Integration |
| TC-INV-03 | INV-286-03 | C1 assertion union | Test unitaire avec set comparison | Unit |
| TC-INV-10 | INV-286-10 | C4 canTransition + trigger PG | Itération sur produit cartésien (états × états) ; vérif équivalence code/trigger | Integration (DB réelle) |
| TC-INV-11 | INV-286-11 | C4 canTransition (terminaux) | Test paramétré : terminal × non-terminal → false | Unit |
| TC-NR-01 | INV-286-05 | C2+C5 sérialisation conditionnelle | Snapshot test PD-85 byte-stable | Integration |
| TC-NR-02 | INV-286-08 | C9 conteneur inchangé | Test consommateur legacy .pvproof (parser PD-85 réutilisé) | Integration |
| TC-NR-03/04/05 | hors périmètre | aucun nouveau code | Tests régression existants (PD-85) ré-exécutés | Integration |
| TC-NEG-01..03 | §5.1 modèle | class-validator (UUID/regex/URL/longueur) | HTTP 400/422 | Unit |
| TC-NEG-04 | totalVolumes=0 | C2 @Min(1) class-validator | HTTP 500 INVALID_TOTAL_VOLUMES | Unit |
| TC-NEG-05 | volumeIndex<0 | C2 @Min(0) class-validator | HTTP 500 INVALID_VOLUME_INDEX_RANGE | Unit |
| TC-NEG-06..08 | INV-286-10/11 | C4 canTransition | Erreur transition + état inchangé | Unit + Integration |
Périmètre de test
| Niveau | In scope | Hors scope (justification) |
| Unitaire | C1 (partitioner, bornes, atomicité), C2 (validation DTO), C4 (state machine), C8 (volume-verifier hash), C9 (assembler multi), C10 (Zod) | — |
| Intégration | C5 (controller end-to-end backend+DB+stockage stub), C6 (audit synchrone DB réelle, fail-closed), C7 (orchestrator complet avec backend stubé), C9 (assembler avec fixtures volumes) | — |
| E2E | TC-NOM-01 (500MB), TC-NOM-02 (2GB), TC-ERR-04 (corruption), TC-ERR-05 (TTL) sur staging avec storage S3 réel | Tests > 5GB et 10GB exécutés en CI nightly uniquement (coûts stockage), test 11GB couvert en unit + integration via mock metadata |
Tous les niveaux sont couverts, aucune exclusion contractuelle. Couverture minimale 80% lignes + 80% branches sur C1-C10.
6. Gestion des erreurs
6.1 Erreurs métier (HTTP 4xx) — émises par C5
| Code spec | HTTP | Body code | Trigger | Audit |
| ERR-286-01 | 413 | EXPORT_TOTAL_LIMIT_EXCEEDED | Σ totalSize > 10_737_418_240 | append export.rejected{reason:"total_limit"} |
| ERR-286-02 | 413 | PROOF_TOO_LARGE | ∃ proof, proof.bytes > 10_737_418_240 | append export.rejected{reason:"proof_too_large"} |
| ERR-286-08 | 400 ou 422 | INVALID_EXPORT_ID / VOLUME_INTEGRITY_HASH_INVALID / SIGNED_URL_INVALID selon champ | class-validator | aucun (avant état REQUESTED) |
6.2 Erreurs internes (HTTP 5xx) — émises par C5/C2
| Code spec | HTTP | Body code | Trigger | Audit |
| ERR-286-03 | 500 | VOLUME_INTEGRITY_HASH_INVALID | hash format invalide en sortie backend (assertion contrat) | append export.failed{reason:"invalid_hash_format"} |
| §5.1 | 500 | INVALID_VOLUME_INDEX_RANGE | volumeIndex hors [0, totalVolumes-1] | append export.failed |
| §5.1 | 500 | INVALID_TOTAL_VOLUMES | totalVolumes < 1 | append export.failed |
| §5.1 | 500 | MANIFEST_ROOT_HASH_INVALID | manifestRootHash regex KO | append export.failed |
| §5.1 | 500 | VOLUME_MANIFEST_INVALID | manifest non canonicalisable | append export.failed |
6.3 Erreurs côté app
| Cas | Mécanisme | Effet |
| Hash mismatch (ERR-286-04) | volume-verifier.verify() throw HashMismatchException | abort orchestrator → state FAILED ; audit local + remontée serveur |
| Erreur réseau (ERR-286-07) | api-client.fetchVolume() retry policy bornée (3 tentatives, backoff exponentiel) puis throw | abort → state FAILED |
| TTL expiré (ERR-286-05) | watchdog app + check serveur sur reprise | state EXPIRED, reprise refusée |
| Trous/doublons volumes[] (ERR-286-06) | C10 validation Zod en réception | state FAILED avant tout téléchargement |
| Données malformées (ERR-286-08) | C10 validation Zod | state FAILED ; pas d'assemblage |
6.4 Anti-catch-absorb (learning 2026-03-08)
Tout catch qui appelle l'audit DOIT propager l'erreur :
// CORRECT — pattern obligatoire dans C5/C6
try {
await this.export(exportId);
} catch (err) {
await this.exportAudit.appendEnd(exportId, 'FAILED', { error: err.message });
throw err;
}
// INTERDIT (forbidden YAML)
.catch(err => this.logger.error(`audit failed: ${err.message}`));
7. Impacts sécurité
7.1 Risques et mitigations
| Risque | Mitigation | Composant |
| Corruption volume en transit (man-in-the-middle, défaut storage) | INV-286-07 : recalcul SHA3-256 sur JCS canonique du manifest reçu, comparaison stricte case-sensitive | C8 |
| Anti-énumération via codes erreur (learning 2026-03-08) | Codes erreur uniformes côté API publique : EXPORT_TOTAL_LIMIT_EXCEEDED/PROOF_TOO_LARGE documentés mais pas de différenciation « inexistant » vs « non autorisé » sur les ressources existantes (exportId) — réutilise la politique PD-85 | C5 |
Réutilisation exportId post-FAILED/EXPIRED | INV-286-11 + spec §4 : transitions terminal → * interdites ; nouvelle requête obligatoire | C4 |
| Audit append-only manipulé | Table audit en append-only via permissions PG (REVOKE UPDATE,DELETE) + trigger prevent_audit_mutation (réutilise pattern PD-287 audit-journal) | C6 |
Énumération de fichiers via signedUrl | signedUrl opaque, TTL borné (SIGNED_URL_TTL 1h..72h) ; pas de pattern d'URL prédictible (HMAC) | externe (storage signer) |
pvproof final exposant clés | Conteneur inchangé (PD-85 baseline) ; aucun nouveau champ sensible ; assembled_from[] ne contient que volumeIndex, integrityHash, estimatedBytes (pas de clé) | C9 |
Math.random() pour exportId (anti-pattern Sonar S2245) | crypto.randomUUID() exclusivement (learning 2026-02-21) — déjà respecté PD-85 | C5 |
7.2 Journalisation
Logs structurés (JSON) avec corrélation exportId :
| Log key | Niveau | Composant | Contenu |
export_started | info | C5 | exportId, totalSize, volumes_count, mode (single|multi) |
partition_summary | info | C1 | exportId, volumes:[{volumeIndex, proof_ids[], estimatedBytes}] |
volume_built | debug | C3 | exportId, volumeIndex, estimatedBytes, integrityHash[:8] |
volume_hash_verified | info | C8 | exportId, volumeIndex (pas le hash complet) |
volume_hash_mismatch | warn | C8 | exportId, volumeIndex, expected[:8], recomputed[:8] |
state_transition | info | C4/C7 | exportId, from, to |
state_transition_rejected | warn | C4 | exportId, from, to_attempted |
export_completed | info | C5/C6 | exportId, volumes_count, duration_ms |
export_failed | error | C5/C6 | exportId, reason, error_code |
INTERDIT : log de manifest complet, log de signedUrl complète, log de integrityHash complet (uniquement les 8 premiers chars pour debug).
- WORM audit (INV-286-09) : append-only, hash chain (réutilise pattern audit existant), corrélation
exportId. Conserve la conformité probatoire PD-85. - Rétention temp files : politique existante PD-85 réutilisée (Q-286-03 ouverte côté produit, ne bloque pas l'implémentation — cf. §9).
- TTL bornes config :
SIGNED_URL_TTL et EXPORT_SESSION_TTL (1h..72h, défaut 24h) ; démarrage service refusé si valeur hors bornes (fail-closed).
8. Hypothèses techniques
| ID | Hypothèse | Impact si faux | Mitigation |
| H-PD286-PLAN-01 | RFC 8785 (JCS) implémenté de manière byte-identical entre Node.js (backend) et React Native + Hermes (app) (reprend H-286-01 spec) | Hash mismatch systématique sur manifest contenant Unicode non-ASCII, floats, ou clés ordonnées différemment → faux négatifs d'intégrité | Suite CI roundtrip dédiée : génère 100 manifests aléatoires (incl. accents, emojis, floats edge) côté backend, calcule hash, transmet à un test app jest qui recalcule et compare. Bloque le merge si divergence. Réutilise lib partagée (publication interne npm @probatiovault/jcs). |
| H-PD286-PLAN-02 | Le métadata proof.bytes en DB (PD-85) reflète exactement la taille du fichier chiffré téléchargeable | Calcul totalSize faux → mauvaise partition (volume > 805_306_368 ou découpe inutile) | Assertion à la lecture : proof.bytes === actual_file_size lors de la génération du signedUrl, throw 500 si divergence (fail-closed) |
| H-PD286-PLAN-03 | class-transformer @Expose conditionnel suffit à omettre volumes/totalVolumes/manifestRootHash dans la réponse legacy | Réponse legacy contient null ou undefined sérialisé → casse consommateur PD-85 strict | Test snapshot byte-stable (TC-NR-01) en CI ; si fail, fallback sur deux DTO distincts (SingleVolumeResponseDto vs MultiVolumeResponseDto en union) |
| H-PD286-PLAN-04 | Le conteneur .pvproof (PD-85) tolère l'ajout de champs volumes_count et assembled_from[] dans pvproof.json interne sans casse des consommateurs externes | Outil tiers de validation rejette le .pvproof enrichi | Test TC-NR-02 vérifie qu'un consommateur PD-85 valide encore. Si gap : promotion vers gap doc (compatibilité ascendante) — pvproof_format_version bumpé à 2 dans pvproof.json, lecteurs PD-85 ignorent les champs inconnus |
| H-PD286-PLAN-05 | L'horloge backend (référence) et l'horloge app (Date.now()) ont une dérive < SIGNED_URL_TTL (≥ 1h) | Faux EXPIRED côté app sur signed URL pourtant valides côté backend | App vérifie via expires_at retourné par backend (déjà dans signedUrl PD-85) plutôt que calcul local ; horloge backend = source unique de vérité (cf. §9) |
| H-PD286-PLAN-06 | Le téléchargement séquentiel (≠ parallèle) est acceptable côté UX pour 2GB sur 4G (~5-10 min) | Frustration utilisateur si trop lent | Hors périmètre §2 spec ; future story possible pour parallélisation (ROI mesurable post-MVP) |
9. Points de vigilance (risques, dette, pièges)
9.1 Dette technique reconnue
- Q-286-03 (rétention) : politique post-FAILED/EXPIRED non chiffrée par produit. Plan retient la politique PD-85 existante par défaut. Ticket de suivi à ouvrir si l'audit RGPD demande une durée explicite.
- Q-286-04 (TTL prod) : valeurs déployées non confirmées. Plan retient les défauts spec (24h/24h). Configurable par env, audité au démarrage (fail-closed si hors bornes).
- Worker EXPIRED : nouveau cron 30s à provisionner sur ProbatioVault-backend. Réutilise patron de worker existant (
@nestjs/schedule) si déjà présent, sinon création.
9.2 Pièges connus
integrityHash case-sensitive (§5.1) : test négatif explicite TC-NEG-02 avec hash en majuscules. Régression facile si une lib normalise en majuscules — couvert par regex stricte /^[0-9a-f]{64}$/ côté backend ET app (C2 + C10). - JCS RFC 8785 cross-runtime : lib npm
canonicalize la plus utilisée n'est PAS officiellement RFC 8785 conforme sur tous les edge cases (NaN, -0, surrogates Unicode). Plan impose la lib partagée interne (cf. H-PD286-PLAN-01) et bloque tout import direct de canonicalize ou autre alternative. - Ordre de tri
volumes[] (review §3.14) : la spec n'impose pas l'ordre dans la réponse. Plan impose : backend renvoie volumes[] triés par volumeIndex ascendant ; app re-trie défensivement (validation Zod refuse trous, doublons, mais pas désordre serveur). Couvre l'ambiguïté review. - Diagramme d'état spec §5bis incomplet (review §3.15) : certaines transitions interdites (ex:
PLANNED_MULTI → PLANNED_SINGLE) ne sont visibles que dans le texte §4. Le code C4 doit s'aligner sur le texte, pas sur le diagramme. Test TC-INV-10 itère sur le produit cartésien complet. assembled_from[] cardinalité : INV-286-08 impose cardinalité = totalVolumes. Vérifié à la finalisation par pvproof-assembler ; throw si divergence (fail-closed).
9.3 Cohérence avec PD-85
PD-286 étend PD-85 sans le casser. Tous les fichiers existants (complaint-file.controller.ts, export.service.ts, pvproof-assembler.ts, state-machine.ts) sont étendus, jamais réécrits intégralement. Les tests PD-85 doivent continuer à passer (TC-NR-01..05).
9.4 Mécanismes cross-module
Aucune modification d'autres modules.
PD-286 modifie uniquement src/modules/export/ côté backend et src/export/ côté app, ainsi que src/__tests__/ et test/. Aucun guard/middleware/interceptor n'est ajouté sur les routes d'autres modules. La consommation des entités proofs (lecture seule via le service existant PD-85) ne change pas.
10. Hors périmètre
- Configuration utilisateur/admin des seuils
VOLUME_MAX_BYTES et MAX_TOTAL_EXPORT_BYTES (figés au build). - Modification du format conteneur
.pvproof (seul pvproof.json interne enrichi). - Téléchargement parallèle des volumes (séquentiel uniquement par décision spec §2).
- Reprise d'export après FAILED/EXPIRED sur le même
exportId (nouvelle requête obligatoire). - Modification des guards auth/premium et du rate limiting (PD-85 inchangé).
- Modification de la pipeline de validation des preuves (entrée du partitionnement = preuves déjà validées).
- Optimisation du stockage temporaire (réutilise le même backend storage que PD-85).
- Bumping du
pvproof_format_version à 2 — décision conditionnelle au résultat de TC-NR-02 (cf. H-PD286-PLAN-04). Si bumping nécessaire, story de suivi.