Aller au contenu

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).

7.3 Conformité

  • 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.