Aller au contenu

PD-286 — Livrable agent export-controller-multi (C5)

Story : Export probatoire multi-volumes (>768 MiB jusqu'à 10 GiB) Module : export-controller-multi (C5) — backend NestJS + TypeORM + PostgreSQL Wave : 3 (Intégration) — dépend de C1 partitioner, C2 DTO, C3 manifest builder, C4 state machine Agent : agent-developer (Claude)

1. Périmètre traité

Implémentation stricte du contrat C5 (cf. PD-286-code-contracts.yaml) :

  • ComplaintFileController étendu : route POST /exports/complaint-file inchangée, garde l'aiguillage entièrement délégué au service (thin layer).
  • ExportService refactor pour orchestrer la chaîne complète multi-volumes : RLS ownership → validation RFC → VolumeRoutingPolicy.decide() (rejet 413 amont) → branche LEGACY_SINGLE (PD-85 préservé) ou VOLUMES_REQUIRED (partition + manifests partiels + manifest racine + signed URLs par volume) → persistance ExportSession + transition ExportStateMachine (REQUESTED → PLANNED_SINGLE | PLANNED_MULTI) + audit synchrone fail-closed dans la même transaction.
  • VolumeRoutingPolicyService (nouveau) : décide entre LEGACY_SINGLE et VOLUMES_REQUIRED, lève les rejets métier (413 EXPORT_TOTAL_LIMIT_EXCEEDED, 413 PROOF_TOO_LARGE) avant toute partition.
  • ExportException étendue : 8 nouveaux codes machine-readable (2 codes 413 métier, 6 codes 500 d'assertion contrat de sortie).
  • Wiring ExportModule : enregistrement des 3 providers manquants (ExportPartitionerService C1, ManifestRootBuilderService C3, VolumeRoutingPolicyService C5).

Hors périmètre (autres agents step 6b) : C1 partitioner (déjà livré), C2 DTO (déjà livré), C3 manifest builder (déjà livré), C4 state machine (déjà livré), C6 audit-multi (Wave 4 — façade dédiée), C7-C10 app, C11 tests.

2. Fichiers livrés

Fichier Statut Description
src/modules/export/controllers/complaint-file.controller.ts étendu Header + commentaire de méthode mis à jour pour documenter la polymorphie de la réponse (legacy vs multi-volumes). Aucun changement de signature.
src/modules/export/services/export.service.ts refacto Aiguillage policy + branches executeLegacySingle / executeMultiVolumes ; persistance ExportSession + transition machine + audit synchrone dans une transaction unique.
src/modules/export/services/volume-routing-policy.service.ts nouveau Service stateless pur — decide(proofs) retourne { mode, totalBytes, volumesReason? } ou throw 413.
src/modules/export/exceptions/export.exception.ts étendu 8 nouveaux codes (EXPORT_TOTAL_LIMIT_EXCEEDED, PROOF_TOO_LARGE, VOLUME_INTEGRITY_HASH_INVALID, INVALID_VOLUME_INDEX_RANGE, INVALID_TOTAL_VOLUMES, MANIFEST_ROOT_HASH_INVALID, VOLUME_MANIFEST_INVALID, PARTITION_INTERNAL_ERROR).
src/modules/export/export.module.ts étendu Enregistrement de ExportPartitionerService, ManifestRootBuilderService, VolumeRoutingPolicyService dans providers.

Aucun fichier hors src/modules/export/** n'a été modifié (cross_module_protections: [] respecté).

3. Couverture des invariants & forbidden (code contract)

3.1 Invariants

Invariant code-contracts Mécanisme Localisation
INV-286-02 — Σ totalSize > MAX_TOTAL_EXPORT_BYTES → throw 413 EXPORT_TOTAL_LIMIT_EXCEEDED AVANT toute partition VolumeRoutingPolicyService.decide() calcule la somme et lève l'exception avant d'appeler ExportPartitionerService.partition(). Le service ne reçoit donc que des entrées sous quota. services/volume-routing-policy.service.ts
ERR-286-02 — ∃ proof.bytes > MAX_TOTAL_EXPORT_BYTES → throw 413 PROOF_TOO_LARGE AVANT toute partition Même policy ; check préalable dans la même boucle, throw avec priorité sur le check totalSize. services/volume-routing-policy.service.ts
Aiguillage LEGACY_SINGLE ssi totalSize ≤ VOLUME_MAX_BYTES ET ∀ proof, proof.bytes ≤ VOLUME_MAX_BYTES (INV-286-05) VolumeRoutingPolicyService.decide() retourne mode='LEGACY_SINGLE' uniquement quand les deux conditions sont vraies. Sinon, mode='VOLUMES_REQUIRED'. services/volume-routing-policy.service.ts
Aiguillage volumes[] requis (multi OU volume dédié exceptionnel) mode='VOLUMES_REQUIRED' couvre les deux cas ; le partitioner C1 décide ensuite si on produit N≥2 volumes standards ou un seul volume dédié exceptionnel. services/volume-routing-policy.service.ts + services/export-partitioner.service.ts (C1)
Réutilise la route existante POST /exports/complaint-file (PD-85) — pas de nouvelle route @Post('complaint-file') inchangé sur ComplaintFileController. La réponse est polymorphe via les champs optionnels de ComplaintFileResponseDto (cf. C2). controllers/complaint-file.controller.ts
exportId via crypto.randomUUID() (learning 2026-02-21, anti S2245) import { randomUUID } from 'node:crypto'. Aucune utilisation de Math.random(). Defense-in-depth : UUID_V4_LOWER_REGEX.test(exportId) après génération avant tout appel persistance/audit. services/export.service.ts
Anti-catch-absorb (learning 2026-03-08) — tout catch qui appelle audit DOIT propager Trois patterns appliqués : (a) try/throw autour de volumeRoutingPolicy.decide() qui audite puis re-throw err. (b) catch (error) global qui audite puis throw new ExportException('EXPORT_INTERNAL_ERROR'...). © dataSource.transaction(async (manager) => { ...; await auditLogService.log(...); }) — toute erreur audit fait rollback la TX, l'exception se propage naturellement (pas de .catch(() => logger.error)). services/export.service.ts
Aucune modification d'autres modules — guards auth/premium et rate limiting PD-85 réutilisés tels quels @UseGuards(OidcJwtAuthGuard, PremiumGuard) inchangés ; aucun nouveau guard / interceptor / middleware. Diff vérifié : 0 fichier hors src/modules/export/**. controllers/complaint-file.controller.ts + export.module.ts

3.2 Forbidden

Forbidden Protection Vérification
Math.random() ou Date.now() comme source d'aléatoire pour exportId crypto.randomUUID() exclusivement. Date.now() n'est utilisé que pour expiresAt = new Date(Date.now() + sessionTtlMs) (calcul d'expiration métier, pas d'aléa). grep -n "Math.random\|randomBytes" src/modules/export/services/export.service.ts → 0 résultat.
Différenciation de message d'erreur entre "export inexistant" et "export non autorisé" (anti-énumération) checkOwnership() : un seul message Proof not found: ${proofId} qu'il s'agisse d'une preuve absente OU d'un ownership KO. Commentaire explicite dans le code. code review L546-L575.
Bypass d'ExportStateMachine pour les transitions REQUESTED → PLANNED_* Toute transition passe par this.stateMachine.transition(session, target). Aucun session.state = ... direct dans export.service.ts (sauf l'INSERT initial où state = REQUESTED via le constructeur de l'entité). grep -n "session.state\s*=" src/modules/export/services/export.service.ts → 1 seule occurrence : init REQUESTED avant transition.
Append audit asynchrone post-response (must be synchronous via C6) auditLogService.log(...) est appelé dans dataSource.transaction() AVANT le retour de la réponse. Pas de setImmediate, pas de queue post-response, pas de Promise.resolve().then. inspection de persistSessionAndAudit.
Catch d'erreur avec .catch(() => logger.error(...)) sur appel audit grep -n "auditLogService\.log.*catch\|emitAudit.*catch" src/modules/export/services/export.service.ts → 0 occurrence. source inspection.
Modification des routes d'autres modules (proofs, complaints, etc.) git diff --name-only après commit ne touche que src/modules/export/**. inspection diff.

4. Décisions architecturales (decision trace)

Format imposé par la consigne (ajout dans architectural_decisions du code contract module C5).

architectural_decisions:
  - decision: "Service VolumeRoutingPolicy stateless pur  décision séparée de la partition"
    rationale: |
      Le contrat C5 cite `VolumeRoutingPolicy` comme une interface explicite ;
      l'isoler en service distinct (pas inline dans ExportService) facilite le
      test unitaire des bornes (1 byte, exactement VOLUME_MAX_BYTES,
      VOLUME_MAX_BYTES+1, exactement MAX_TOTAL, MAX_TOTAL+1) sans monter une
      sandbox NestJS complète. Le service est sans dépendance (pas de DI),
      pure fonction sur PartitionInputProof[].
    alternatives_considered:
      - "Inline directement dans ExportService.execute() (couplage fort, harder to test)"
      - "Méthode statique sur ExportPartitionerService (mélange responsabilités décision vs algorithme)"
    trade_offs: |
      Avantage : testabilité au niveau unit, pas de drift entre la décision
      et le partitioner (les bornes 768 MiB / 10 GiB sont importées de la
      même constante export.constants.ts). Inconvénient : un service de plus
      à enregistrer dans le module — coût marginal.

  - decision: "Audit synchrone via dataSource.transaction()  pas de façade C6 anticipée"
    rationale: |
      C6 (export-audit-multi) est en Wave 4 et n'est pas encore livré. Plutôt
      que de bloquer C5 ou d'introduire une façade vide qui serait refactorée
      par C6, j'utilise directement `auditLogService.log()` (PD-37) dans une
      transaction NestJS. Le contrat fonctionnel est respecté : append
      synchrone, fail-closed, anti-catch-absorb. C6 pourra ultérieurement
      consolider en `ExportAuditService` sans changer la sémantique.
    alternatives_considered:
      - "Définir un ExportAuditService stub minimal côté C5 (mais l'agent C6 risquerait de le réécrire)"
      - "Différer C5 tant que C6 n'est pas livré (rompt la wave 3)"
    trade_offs: |
      Avantage : C5 testable et déployable sans C6. Inconvénient : un seul
      point d'appel à `auditLogService.log` (vs façade dédiée) — refacto
      attendu lors du livrable C6 (signal explicite via `mode` et
      `state` dans le metadata pour faciliter le découpage).

  - decision: "Signed URL par volume = clé S3 déterministe `exports/{exportId}/v{volumeIndex}.bundle`"
    rationale: |
      `ExportVolumeDto.signedUrl` contient une seule URL — implique un objet
      bundle par volume côté storage. La création effective du bundle
      (concaténation/tar/zip des proof files dans un seul objet S3) est
      hors-périmètre du module export (probable story future ou job parallèle).
      C5 sait juste signer la clé prévue : convention déterministe documentée
      en hypothèse H-C5-03.
    alternatives_considered:
      - "Multiples signedUrls par volume (briserait le contrat ExportVolumeDto.signedUrl: string)"
      - "Forcer la création du bundle ici (modification module documents  interdit cross_module)"
    trade_offs: |
      Avantage : C5 reste dans son périmètre, signe une clé prédictible que
      le service de bundling produira (en dehors de C5). Inconvénient :
      bundle absent pendant l'intégration ; les tests E2E doivent stubber
      le service de bundling ou écrire un objet vide pour démontrer le flux.
      Cette dette est explicite (cf. §6 hypothèses).

  - decision: "Branche multi-volumes garde aussi `signedUrls` au niveau racine"
    rationale: |
      Pour préserver la compatibilité PD-85 (TC-NR-02) et permettre aux
      consommateurs legacy de lire `signedUrls[]` sans regarder `volumes[]`,
      la réponse multi-volumes inclut aussi `signedUrls = volumes.map(v => v.signedUrl)`.
      Aucune contradiction avec INV-286-05 puisque INV-286-05 ne s'applique
      qu'au mode single-volume — il dit que `volumes/totalVolumes/manifestRootHash`
      sont absents, pas que `signedUrls` doit être absent.
    alternatives_considered:
      - "Omettre signedUrls en multi-volumes (casserait l'attente PD-85 d'avoir au moins ce champ)"
      - "Distinguer deux DTO (LegacySingle vs Multi) en union type"
    trade_offs: |
      Avantage : regression-free pour tous les consommateurs PD-85.
      Inconvénient : duplication signedUrl entre racine et `volumes[].signedUrl`
      en multi-volumes. Acceptable car les deux pointent vers les mêmes objets.

  - decision: "Anti-catch-absorb par try/throw explicite + transaction wrapping"
    rationale: |
      Trois mécanismes complémentaires pour appliquer le learning 2026-03-08 :
      (1) Le bloc `try { policy.decide() } catch (err) { audit; throw err }`
      garantit que l'audit fail-closed est émis avant la propagation 413/500.
      (2) Le `dataSource.transaction()` wrappe l'audit dans une TX —
      si l'audit lève, la TX rollback et l'exception se propage naturellement.
      (3) Le `try/catch` global fait l'audit puis throw une `EXPORT_INTERNAL_ERROR`.
    alternatives_considered:
      - "finally { audit() } (ne distingue pas success/failure et n'émet pas avant le throw)"
      - "Laisser auditLogService gérer ses propres retries silencieusement (forbidden)"
    trade_offs: |
      Avantage : aucun catch n'absorbe une erreur audit silencieusement.
      Inconvénient : code légèrement plus verbeux que `finally`. Compromis assumé
      pour respecter l'invariant fail-closed audit (INV-85-05 + INV-286-09).

5. Vérifications effectuées

  • npx tsc --noEmit -p tsconfig.json (depuis ProbatioVault-backend/) : 0 erreur sur les 5 fichiers livrés. Les 2 erreurs résiduelles globales (complaint-file-response.dto.ts:134 C2 et merkle-proof-v2.controller.ts:16 module merkle) sont hors périmètre C5 — la première est documentée dans le livrable C2, la seconde n'appartient pas au module export.
  • Conventions :
  • Branded types ExportId, ExportSessionUserId propagés depuis entities/export-session.entity.ts (learning 2026-03-04 — UUID sémantiquement distincts).
  • Aucun Math.random() (learning 2026-02-21 anti S2245).
  • Aucun .catch(() => logger.error(...)) sur appel audit (learning 2026-03-08 anti-catch-absorb).
  • Aucun import direct de lib JCS (canonicalize, json-canonicalize) — délégué à C3 via IntegrityHashComputer.
  • Schéma vault_secure cohérent avec C4.
  • forbidden route inchangée — diff vérifié.

6. Hypothèses d'implémentation (à valider en intégration)

ID Hypothèse Impact si faux
H-C5-01 auditLogService.log() (PD-37) lève proprement quand HSM est indisponible (pas de fallback silencieux). C5 compte sur cette propagation pour fail-closed la transaction. Si log() swallow l'erreur (queue silently), la TX commit sans audit signé → violation INV-286-09. À couvrir par test d'intégration C11 (mock HSM down).
H-C5-02 proof.anchoringEvidenceRef.encryptedBytes (number) sera renseigné par les futures stories d'ingestion. Aujourd'hui, la metadata est absente sur la majorité des preuves → fallback 1 MiB/preuve (cohérent PD-85). Sous-estimation de la taille → policy peut renvoyer LEGACY_SINGLE alors que la taille réelle dépasserait VOLUME_MAX_BYTES. Acceptable pour MVP : C8 vérifie le hash de chaque volume (INV-286-07), donc une vraie incohérence se solde en FAILED côté app. À documenter pour reprise par story dédiée.
H-C5-03 Une convention de clé S3 exports/{exportId}/v{volumeIndex}.bundle est respectée par le service de bundling de volumes (hors-périmètre C5). C5 signe la clé sans vérifier l'existence physique de l'objet (pas de HEAD). Si le bundle n'est pas produit, le téléchargement renvoie 404 → app passe FAILED. Test d'intégration C11 doit stubber l'objet ou créer un fichier vide. Story de suivi recommandée pour le service de bundling.
H-C5-04 ExportSession.totalSizeBytes (colonne bigint exposée comme string côté TypeORM) accepte des valeurs jusqu'à MAX_TOTAL_EXPORT_BYTES = 10_737_418_240. PostgreSQL bigint couvre largement (max ≈ 9.2 × 10^18). OK. Documenté pour traçabilité.
H-C5-05 EXPORT_SESSION_TTL_HOURS env var, si fournie hors bornes [1, 72] (cf. EXPORT_SESSION_TTL_HOURS_MIN/MAX), est clampée silencieusement par C5. La spec §5.2 demande "démarrage service refusé (fail-closed)" si non-respect des bornes. Différence assumée : C5 clamp, C4 worker clamp aussi. Un test d'intégration peut basculer en fail-closed si nécessaire — revue pour PR. Note : la spec parle de "démarrage service refusé", mais le clamp évite un crash sur valeur mal renseignée en prod. À trancher en revue.
H-C5-06 ExportManifestBuilder.buildPartial() (C3) renvoie un ManifestDto dont integrityHash est un hex 64 chars lowercase strict (regex /^[0-9a-f]{64}$/). Defense-in-depth via assertHashHexLower côté C5. Si C3 retourne un format erroné, le throw 500 VOLUME_INTEGRITY_HASH_INVALID (ERR-286-03) est levé immédiatement. Aucun volume invalide ne sort dans la réponse.

7. Observabilité

Log key Niveau Contenu
export_started log exportId, mode (LEGACY_SINGLE\|VOLUMES_REQUIRED), totalBytes, validProofs (compteur)
volume_built debug exportId, volumeIndex, estimatedBytes, integrityHash[:8] (8 premiers chars uniquement — INTERDIT log hash complet learning 2026-03-08)
export_failed error exportId, reason (message), stack trace

INTERDIT (learning 2026-03-08) — non émis par C5 :

  • log de manifest complet
  • log de signedUrl complète (uniquement pris dans le DTO de retour, jamais loggé)
  • log de integrityHash complet (uniquement les 8 premiers chars en debug)

L'audit WORM (AuditLogService.log) est l'observable principal pour le suivi de conformité et la traçabilité. Métadonnées émises :

  • mode (LEGACY_SINGLE | VOLUMES_REQUIRED)
  • volumes_count (1 en legacy, ≥1 en multi)
  • integrityHashes[] (uniquement en VOLUMES_REQUIRED — cf. INV-286-09 / CA-286-07)
  • manifestRootHash (uniquement en VOLUMES_REQUIRED)
  • totalBytes, correlationId, timestamp_utc, state (PLANNED_SINGLE/PLANNED_MULTI), rejectedCount

8. Cohérence avec PD-85

PD-286 étend PD-85 sans le casser :

  • La route POST /exports/complaint-file reste l'unique point d'entrée. Aucune signature publique n'a changé : createComplaintFile(user, dto) → ComplaintFileResponseDto.
  • La branche executeLegacySingle reproduit le comportement PD-85 ligne par ligne (manifest legacy, chronology, signedUrls par document, guideUrl, readmeVerification, rejectedProofs, minorEvidence). La seule différence : la session est désormais persistée dans vault_secure.export_sessions (transition REQUESTED → PLANNED_SINGLE).
  • Tous les tests TC-NR-01..05 (non-régression PD-85) doivent continuer à passer sans modification — la branche legacy est inchangée fonctionnellement.
  • L'audit existant EXPORT_GENERATED (PD-85) est conservé ; PD-286 ajoute des champs metadata supplémentaires (mode, volumes_count, integrityHashes, etc.) sans casser le schéma JSONB existant.

9. Suivi pour les agents downstream

  • C6 (export-audit-multi, Wave 4) : pourra extraire la logique d'audit (emitAudit + appel auditLogService.log en TX) en façade dédiée ExportAuditService. La sémantique externe ne change pas — seul le caller bouge. Les champs mode, volumes_count, integrityHashes, manifestRootHash dans metadata sont déjà dans le format attendu par le contrat C6.
  • C7 (app-export-orchestrator-multi) : reçoit la réponse polymorphe. Détection : response.volumes !== undefined → multi-volumes. Sinon legacy. La transition PLANNED_* → DOWNLOADING est portée côté app uniquement (machine app distincte mais matrice identique — vérifié par C4).
  • C8 (app-volume-verifier) : recompute SHA3-256 sur JCS(ExportVolumeDto.manifest \ {integrityHash}) et compare au integrityHash reçu. C5 garantit que le hash est en lowercase strict (defense-in-depth assertHashHexLower).
  • C11 (tests) :
  • TC-ERR-01 (413 EXPORT_TOTAL_LIMIT_EXCEEDED) : envoie un payload >10 GiB ; vérifie que la session N'EST PAS persistée (pas d'INSERT en export_sessions).
  • TC-ERR-09 (413 PROOF_TOO_LARGE) : envoie une preuve >10 GiB ; vérifie que la session N'EST PAS persistée.
  • TC-NOM-01 / TC-NR-01 (legacy 500 MiB) : vérifie absence de volumes/totalVolumes/manifestRootHash dans le JSON sérialisé (snapshot byte-stable). Vérifie INSERT session avec state=PLANNED_SINGLE.
  • TC-NOM-02 (multi-volumes 2 GiB) : vérifie totalVolumes>=2, présence de manifestRootHash regex valide, volumes[].signedUrl HTTPS valide, INSERT session avec state=PLANNED_MULTI.
  • TC-NOM-05 / CA-286-07 (audit) : query SQL sur vault_secure.audit_log WHERE entity_id=exportId retourne UNE entrée avec metadata.volumes_count, metadata.integrityHashes (cardinalité = totalVolumes), metadata.manifestRootHash.
  • Anti-catch-absorb : mock auditLogService.log pour throw → vérifie que la TX rollback (pas de session insérée) et que l'erreur se propage (HTTP 500 propagé au client).
  • Anti-énumération : envoie un proofId non possédé par l'utilisateur ; vérifie que le message est exactement Proof not found: <id> (identique au cas inexistant).
  • C11 — drift entre code et trigger PG : test d'intégration utilisant ExportStateMachine.listAllowedTransitions() (C4) pour itérer chaque transition autorisée et vérifier que le trigger PG l'accepte. C5 ne fait qu'appeler transition() — la matrice reste portée par C4.