Aller au contenu

PD-253 — Décomposition (Étape 6a)

Story : PD-253 — Export bulk réversible avec métadonnées et preuves probatoires Auteur : Claude (orchestrateur ProbatioVault) Date : 2026-03-12 Statut : Approuvé Gate 5 — Prêt pour étape 6b


1. Stratégie de parallélisation

Principe directeur

Le module BulkExportModule est un nouveau module NestJS distinct, ce qui permet une parallélisation maximale sans risque de régression sur les modules existants. La stratégie retenue est by_level : les agents travaillent en parallèle sur les tâches sans dépendances inter-agents au sein d'un même niveau, et les niveaux se succèdent séquentiellement.

Analyse des dépendances

Le graphe de dépendances des 12 code contracts (CC-253-01 à CC-253-12) fait apparaître 6 niveaux naturels :

  1. Niveau 0 — Fondations : CC-253-01 (entité/migration) et CC-253-02 (configuration) sont indépendants. Ils définissent le socle sur lequel tous les autres s'appuient. CC-253-01 est le prérequis de la quasi-totalité des services.

  2. Niveau 1 — DTOs et périmètre : CC-253-03 (DTOs) et CC-253-06 (ExportScopeService) dépendent de CC-253-01 mais sont indépendants entre eux. Ils peuvent être développés en parallèle.

  3. Niveau 2 — Services cœur : CC-253-04 (Quota + StateMachine), CC-253-08 (BagIt + DualManifest), CC-253-09 (DestructionLog) et CC-253-12 (Cleanup + Scheduler) dépendent de CC-253-01 mais sont mutuellement indépendants — 4 agents en parallèle.

  4. Niveau 3 — Services dépendants : CC-253-10 (ExportDownloadService) dépend de CC-253-04 et CC-253-05 ; CC-253-11 (ExportSigningService) dépend de CC-253-08.

  5. Niveau 4 — Orchestration : CC-253-05 (BulkExportService + Controller) dépend de CC-253-01, CC-253-03, CC-253-04 — séquentiel obligatoire.

  6. Niveau 5 — Processor : CC-253-07 (BulkExportProcessor) est le nœud intégrateur qui orchestre tous les services — dernière tâche de code avant les tests.

Justification des choix

  • La séparation agent-config (niveau 0) / agent-developer (niveaux 1-5) est justifiée par la nature des tâches : infrastructure DB et configuration vs logique métier.
  • La branche par niveau (git strategy: branch_per_level) garantit que les merges sont propres et que les conflits sont localisés.
  • L'ajout d'une phase tests (niveau 6) en parallèle maximise le throughput en évitant que les agents QA attendent la fin des agents développeurs.
  • Le BulkExportProcessor (niveau 5) est développé en dernier car il orchestre CC-253-06 (scope), CC-253-08 (bagit), CC-253-09 (destruction-log), CC-253-10 (download), CC-253-11 (signing), CC-253-12 (cleanup) — aucun de ces services ne peut être mocké sans risquer de rater des invariants critiques (INV-253-01, INV-253-12).

2. DAG des dépendances

Niveau 0 (sans dépendances)
├── CC-253-01 : Entité BulkExport + migration DDL
│     └── produit : BulkExport entity, BulkExportStatus, BulkExportScope,
│                   BulkExportFailureReason enums, branded types BulkExportId/UserId
└── CC-253-02 : Configuration BulkExportModule
      └── produit : BulkExportConfig, valeurs SLA (TTL, retention, timeout, maxGB)

Niveau 1 (dépend de CC-253-01)
├── CC-253-03 : DTOs requête/réponse
│     ├── dépend : CC-253-01 (enums BulkExportStatus, BulkExportScope, BulkExportFailureReason)
│     └── produit : CreateBulkExportDto, BulkExportResponseDto, ConfirmDownloadResponseDto
└── CC-253-06 : ExportScopeService
      ├── dépend : CC-253-01 (BulkExport entity, BulkExportScope)
      └── produit : resolveDocuments(), buildDocumentEntry(), DocumentExportEntry interface

Niveau 2 (dépend de CC-253-01)
├── CC-253-04 : ExportQuotaService + ExportStateMachineService
│     ├── dépend : CC-253-01 (BulkExport entity, BulkExportStatus, branded types)
│     └── produit : hasActiveExport(), transition(), isTerminal()
├── CC-253-08 : BagItAssemblerService + DualManifestService
│     ├── dépend : CC-253-01 (BulkExport entity, BulkExportScope)
│     └── produit : assemble(), generate(), verify(), structure BagIt RFC 8493
├── CC-253-09 : DestructionLogService
│     ├── dépend : CC-253-01 (BulkExportId branded type)
│     └── produit : buildLog(), DestructionLog interface (ECT-02)
└── CC-253-12 : ExportCleanupService + ExportExpiryScheduler
      ├── dépend : CC-253-04 (transition, pour expireExport())
      └── produit : purgeStaleTemp(), expireExport(), scheduler CRON 15min

Niveau 3 (dépend niveaux 1 et 2)
├── CC-253-10 : ExportDownloadService
│     ├── dépend : CC-253-04 (StateMachine pour confirmDownload), CC-253-05 (BulkExportService)
│     └── produit : generateSignedUrl(), confirmDownload()
└── CC-253-11 : ExportSigningService
      ├── dépend : CC-253-08 (BagItPackage interface)
      └── produit : signPackage(), export.sig optionnel (niveau strong)

Niveau 4 (dépend niveaux 1 et 2)
└── CC-253-05 : BulkExportService + BulkExportController
      ├── dépend : CC-253-01 (entity), CC-253-03 (DTOs), CC-253-04 (quota, statemachine)
      └── produit : createExport(), getExport(), cancelExport(), confirmDownload()
                   + endpoints REST POST/GET/DELETE

Niveau 5 (dépend de tout)
└── CC-253-07 : BulkExportProcessor (BullMQ worker)
      ├── dépend : CC-253-05 (BulkExportService), CC-253-06 (ExportScopeService),
      │           CC-253-08 (BagItAssemblerService), CC-253-09 (DestructionLogService),
      │           CC-253-10 (ExportDownloadService), CC-253-11 (ExportSigningService),
      │           CC-253-12 (ExportCleanupService)
      └── produit : process(), orchestration complète du workflow d'assemblage asynchrone

Niveau 6 (parallèle — tests + module barrel)
├── Tests unitaires BulkExportService (dépend CC-253-05)
├── Tests unitaires ExportStateMachineService (dépend CC-253-04)
├── Tests E2E BulkExport endpoints (dépend CC-253-05 + CC-253-07)
└── BulkExportModule barrel + registration (dépend de tous)

3. Niveaux de parallélisation

Niveau 0 (fondations — sans dépendances)

Ces deux tâches peuvent démarrer immédiatement et en parallèle. Elles produisent les contrats de types consommés par tous les agents suivants.

  • Task 1 — CC-253-01 : Entité BulkExport + migration DDL
  • Agent: agent-config
  • Branche: feature/PD-253-l0-entities-migration
  • Fichiers:
    • src/modules/bulk-export/entities/bulk-export.entity.ts
    • src/modules/bulk-export/enums/bulk-export-status.enum.ts
    • src/modules/bulk-export/enums/bulk-export-scope.enum.ts
    • src/modules/bulk-export/enums/bulk-export-failure-reason.enum.ts
    • src/modules/bulk-export/interfaces/bulk-export-scope-params.interface.ts
    • src/database/migrations/1742000000000-PD253-CreateBulkExports.ts
  • Invariants couverts: INV-253-13 (contrainte CHECK état), INV-253-14 (colonne atomique)
  • Contraintes critiques:
    • Branded types BulkExportId et UserId obligatoires (learning PD-282)
    • Migration idempotente : CREATE TABLE IF NOT EXISTS uniquement
    • Aucun ALTER TYPE ADD VALUE — stratégie VARCHAR + CHECK pour éviter le trap PostgreSQL (learning PD-282, PD-279)
    • Aucune modification de table existante
    • Math.random() interdit — crypto.randomUUID() uniquement (learning PD-63)
  • Dependencies: []

  • Task 2 — CC-253-02 : Configuration BulkExportModule

  • Agent: agent-config
  • Branche: feature/PD-253-l0-config
  • Fichiers:
    • src/modules/bulk-export/config/bulk-export.config.ts
    • src/config/config.schema.ts (extension Joi uniquement)
  • Invariants couverts: INV-253-07 (quota SLA), SLA §5.2/§5.3
  • Paramètres et bornes Joi:
    BULK_EXPORT_DOWNLOAD_URL_TTL_H   : défaut 24, min 1, max 72
    BULK_EXPORT_PACKAGE_RETENTION_H  : défaut 168, min 24, max 720
    BULK_EXPORT_JOB_TIMEOUT_H        : défaut 24, min 1, max 72
    BULK_EXPORT_MAX_PACKAGE_GB       : défaut 100, min 1, max 100
    BULK_EXPORT_QUEUE_NAME           : 'pv-jobs-bulk-export' (sans ':')
    
  • Contraintes critiques:
    • Rejet strict (pas de clamp) si hors bornes — Joi.number().min(x).max(y) sans valeur par défaut forcée
    • allowUnknown: false, abortEarly: false
    • Nom de queue sans ':' (learning BullMQ v5 PD-55) — PAS getRepeatableJobs() / removeRepeatableByKey() (deprecated BullMQ v5)
    • Valeurs codées en dur interdites — toujours via ConfigService
  • Dependencies: []

Niveau 1 (dépend du niveau 0)

Ces deux tâches peuvent démarrer dès que CC-253-01 est mergée. Elles sont indépendantes entre elles.

  • Task 3 — CC-253-03 : DTOs requête/réponse
  • Agent: agent-developer
  • Branche: feature/PD-253-l1-dtos
  • Fichiers:
    • src/modules/bulk-export/dto/create-bulk-export.dto.ts
    • src/modules/bulk-export/dto/bulk-export-response.dto.ts
    • src/modules/bulk-export/dto/confirm-download-response.dto.ts
  • Invariants couverts: INV-253-07 (validation scope case-sensitive), INV-253-13 (réponse status typée)
  • Contrats:
    • CreateBulkExportDto : scope avec @IsEnum(BulkExportScope) (case-sensitive, rejet si minuscule — TC-NEG-01)
    • from / to : @IsISO8601() + validation from <= to dans le service
    • document_ids si scope SELECTION : @IsArray @ArrayMinSize(1) @IsUUID('4', {each: true}) @ArrayUnique()
    • BulkExportResponseDto : failure_reason nullable, type BulkExportFailureReason | null
    • Champ package_s3_key interdit dans la réponse (chemin S3 interne — anti-énumération)
    • String libre pour failure_reason interdite — enum typé uniquement
  • Dependencies: [CC-253-01]

  • Task 4 — CC-253-06 : ExportScopeService

  • Agent: agent-developer
  • Branche: feature/PD-253-l1-export-scope
  • Fichiers:
    • src/modules/bulk-export/services/export-scope.service.ts
    • src/modules/bulk-export/interfaces/document-export-entry.interface.ts
  • Invariants couverts: INV-253-01 (exhaustivité GLOBAL), INV-253-09 (soft-deleted inclus, détruits exclus)
  • Contrats:
    • resolveDocuments(export: BulkExport): Promise<DocumentExportEntry[]>
    • buildDocumentEntry(documentId, userId): Promise<DocumentExportEntry | null>
    • Scope GLOBAL : sélectionne TOUS les DocumentSecure du user_id avec status NOT IN ('DESTROYED') — y compris EXPIRED (soft-deleted). Comptage source stocké dans job metadata pour INV-253-01.
    • Scope VAULT : filtre sur vault_id (champ scope_params.vault_id)
    • Scope PERIOD : filtre sur created_at BETWEEN from AND to
    • Scope SELECTION : filtre sur id IN (document_ids) avec vérification ownership stricte
    • Comptage source == comptage export éligibles avant génération package. Mismatch → FAILED avec SCOPE_RESOLUTION_FAILED
    • Inclusion de documents d'un autre user_id : INTERDIT
    • Lecture du contenu S3 des documents : INTERDITE (Zero-Knowledge)
  • Dependencies: [CC-253-01]

Niveau 2 (dépend de CC-253-01)

Quatre agents peuvent travailler en parallèle. Ces services cœur sont mutuellement indépendants.

  • Task 5 — CC-253-04 : ExportQuotaService + ExportStateMachineService
  • Agent: agent-developer
  • Branche: feature/PD-253-l2-quota-statemachine
  • Fichiers:
    • src/modules/bulk-export/services/export-quota.service.ts
    • src/modules/bulk-export/services/export-state-machine.service.ts
    • src/modules/bulk-export/exceptions/bulk-export.exceptions.ts
  • Invariants couverts: INV-253-07 (quota 1 actif/user), INV-253-13 (transitions explicites), INV-253-14 (atomicité)
  • Contrats:
    • ExportQuotaService.hasActiveExport(userId: UserId): Promise<boolean> — COUNT(*) WHERE user_id = ? AND status IN ('REQUESTED','ASSEMBLING','READY_FOR_DOWNLOAD')
    • ExportStateMachineService.transition(export: BulkExport, to: BulkExportStatus): void — throws BulkExportTransitionException si interdite
    • ExportStateMachineService.isTerminal(status: BulkExportStatus): boolean
    • Matrice transitions conforme spec §5.4 : REQUESTED→{ASSEMBLING,CANCELLED}, ASSEMBLING→{READY_FOR_DOWNLOAD,FAILED,FAILED_TIMEOUT,CANCELLED}, READY_FOR_DOWNLOAD→{DOWNLOADED,EXPIRED}
    • États terminaux : DOWNLOADED, EXPIRED, FAILED, FAILED_TIMEOUT, CANCELLED — toute sortie INTERDITE
    • Libération quota implicite — COUNT filtre les terminaux, pas de slot explicite (ECT-06)
    • Tout UPDATE de statut sans passage par ExportStateMachineService : INTERDIT
    • Transition directe REQUESTED → READY_FOR_DOWNLOAD : INTERDITE
  • Dependencies: [CC-253-01]

  • Task 6 — CC-253-08 : BagItAssemblerService + DualManifestService

  • Agent: agent-developer
  • Branche: feature/PD-253-l2-bagit-manifest
  • Fichiers:
    • src/modules/bulk-export/services/bagit-assembler.service.ts
    • src/modules/bulk-export/services/dual-manifest.service.ts
    • src/modules/bulk-export/interfaces/bagit-package.interfaces.ts
  • Invariants couverts: INV-253-02 (ProofEnvelope complète), INV-253-03 (dual-hash), INV-253-04 (dual-manifest), INV-253-05 (intégrité package)
  • Structure BagIt produite:
    {export_id}/
      bagit.txt                     (BagIt declaration RFC 8493)
      bag-info.txt                  (métadonnées package: org, date, taille)
      manifest-sha256.txt           (hash SHA-256 de chaque fichier data/)
      manifest-sha3.txt             (hash SHA3-256 de chaque fichier data/)
      data/
        {document_id}/
          content.enc               (binaire chiffré WORM — copie depuis S3)
          metadata.json             (métadonnées document + ProofEnvelope)
      metadata/
        export-manifest.json        (résumé export: scope, dates, comptages)
        destruction-log.json        (ECT-02 — documents détruits exclus)
      export.sig                    (optionnel — niveau strong)
    
  • Contrats BagItAssemblerService:
    • assemble(entries: DocumentExportEntry[], export: BulkExport): Promise<BagItPackage>
    • plaintext_hash et ciphertext_hash : regex ^[a-f0-9]{64}$ vérifiée fonctionnellement (validation fonctionnelle ≠ format — learning PD-283). Si invalide → export FAILED.
    • blockchain_anchor.status conservé tel quel — jamais "upgradé" vers anchored (INV-253-08)
    • is_deleted et deleted_at dans metadata.json si applicable
    • Encodage UTF-8 sans BOM pour tous les JSON
  • Contrats DualManifestService:
    • generate(packageDir: string): Promise<{ sha256Manifest: string, sha3Manifest: string }>
    • verify(packageDir: string): Promise<{ valid: boolean, errors: string[] }>
    • SHA-256 : crypto.createHash('sha256').update(fileBuffer).digest('hex')
    • SHA3-256 : sha3_256(fileBuffer) via js-sha3 (dépendance existante)
    • Format ligne : <64 chars hex> <path relatif> (2 espaces, séparateur /, sans ..)
    • Tri des fichiers : alphabétique pour reproductibilité
    • Exclus des manifests : manifest-sha256.txt, manifest-sha3.txt, bagit.txt, bag-info.txt, export.sig
    • export.sig dans les manifests : INTERDIT (il signe les manifests, pas l'inverse)
    • path.join() pour les chemins : INTERDIT — utiliser path.posix.join()
  • Dependencies: [CC-253-01]

  • Task 7 — CC-253-09 : DestructionLogService

  • Agent: agent-developer
  • Branche: feature/PD-253-l2-destruction-log
  • Fichiers:
    • src/modules/bulk-export/services/destruction-log.service.ts
    • src/modules/bulk-export/interfaces/destruction-log.interfaces.ts
  • Invariants couverts: INV-253-09 (traçabilité destruction légale)
  • Schéma DestructionLog (ECT-02):
    interface DestructionLogEntry {
      document_id: string;           // UUID v4
      destroyed_at: string;          // RFC3339 UTC (PD-250)
      destruction_act_hash: string;  // hex SHA3-256 64 chars
      destruction_batch_id: string;  // UUID v4, référence batch PD-250
      destruction_reason: string;    // "RETENTION_EXPIRED" | "LEGAL_REQUEST" | "USER_REQUEST"
      bordereau_id: string;          // UUID v4, référence bordereau PD-250 (INV-250-03)
    }
    interface DestructionLog {
      schema_version: "1.0";
      export_id: string;             // UUID v4
      generated_at: string;          // RFC3339 UTC
      total_destroyed: number;
      entries: DestructionLogEntry[];
    }
    
  • Contrats:
    • buildLog(exportId, destroyedEntries: DocumentExportEntry[]): Promise<DestructionLog>
    • destruction_act_hash : lu depuis destruction_bordereau.hash (PD-250), regex ^[a-f0-9]{64}$ vérifiée fonctionnellement (validation fonctionnelle obligatoire — learning PD-283)
    • Si destruction_act_hash invalide → export FAILED avec PROOF_ENVELOPE_INCOMPLETE
    • total_destroyed == entries.length (cohérence interne vérifiée)
    • bordereau_id obligatoire — omettre est INTERDIT (INV-250-03 PD-250)
    • Données personnelles directes (nom, prénom, email) dans les entrées : INTERDITES
    • Encodage UTF-8 sans BOM
  • Dependencies: [CC-253-01]

  • Task 8 — CC-253-12 : ExportCleanupService + ExportExpiryScheduler

  • Agent: agent-developer
  • Branche: feature/PD-253-l2-cleanup-scheduler
  • Fichiers:
    • src/modules/bulk-export/services/export-cleanup.service.ts
    • src/modules/bulk-export/schedulers/export-expiry.scheduler.ts
  • Invariants couverts: INV-253-12 (no-residuals — purgeStale + finally)
  • Contrats:
    • ExportCleanupService.purgeStaleTemp(exportId: BulkExportId): Promise<void> — supprime /tmp/bulk-export-{exportId}/
    • ExportCleanupService.expireExport(exportId: BulkExportId): Promise<void> — transition → EXPIRED + purge S3
    • ExportExpiryScheduler : CRON toutes les 15 min, sélectionne status = 'READY_FOR_DOWNLOAD' ET expires_at <= NOW()
    • expireExport : supprime l'objet S3 via S3Service.deleteObject(packageS3Key) avant de transitionner en DB — retry 3x si S3 échoue (pas de blocage de l'expiration DB)
    • Audit BULK_EXPORT_EXPIRED émis après transition
    • purgeStaleTemp dans finally uniquement : INTERDIT — DOIT être appelé en début de job aussi (learning PD-283, INV-253-12)
    • DOWNLOADED → EXPIRED : INTERDITE — seul READY_FOR_DOWNLOAD transite vers EXPIRED (ECT-05)
    • L'état DOWNLOADED est conservé en DB après purge physique S3 (correction E-02)
  • Note: Cette tâche dépend fonctionnellement de CC-253-04 (ExportStateMachineService) pour expireExport(). L'agent doit importer le service via injection NestJS standard.
  • Dependencies: [CC-253-01, CC-253-04]

Niveau 3 (dépend des niveaux précédents)

Ces deux tâches peuvent démarrer en parallèle dès que leurs dépendances respectives sont disponibles.

  • Task 9 — CC-253-10 : ExportDownloadService
  • Agent: agent-developer
  • Branche: feature/PD-253-l3-export-download
  • Fichiers:
    • src/modules/bulk-export/services/export-download.service.ts
  • Invariants couverts: INV-253-06 (signature optionnelle), ECT-01 (confirm-download endpoint)
  • Contrats:
    • generateSignedUrl(export: BulkExport): Promise<{ url: string, expiresAt: Date }>
    • confirmDownload(userId: UserId, exportId: BulkExportId): Promise<void>
    • generateSignedUrl : URL signée TTL configurable (download_url_ttl_h) via S3Service présignature
    • confirmDownload : idempotent — si déjà DOWNLOADED, retourne 200 sans re-audit (ECT-01)
    • Précondition : état READY_FOR_DOWNLOAD uniquement pour confirmDownload
    • Si état != READY_FOR_DOWNLOAD et != DOWNLOADED : 409 EXPORT_NOT_DOWNLOADABLE
    • Validation existence objet S3 avant génération URL signée (package_s3_key fonctionnellement vérifié — learning PD-283)
    • 410 DOWNLOAD_URL_EXPIRED si URL signée expirée
    • 410 EXPORT_EXPIRED si état EXPIRED
  • Dependencies: [CC-253-04, CC-253-05]

  • Task 10 — CC-253-11 : ExportSigningService

  • Agent: agent-developer
  • Branche: feature/PD-253-l3-export-signing
  • Fichiers:
    • src/modules/bulk-export/services/export-signing.service.ts
  • Invariants couverts: INV-253-06 (signature HSM optionnelle — niveau strong)
  • Contrats:
    • signPackage(packageDir: string, exportId: BulkExportId): Promise<Buffer | null>
    • Signature uniquement si probative_level = 'strong' — null retourné si standard
    • Périmètre signé : digest SHA3-256 du package final (PC-253-01 — option contractuelle provisoire)
    • export.sig NE DOIT PAS être inclus dans les manifests (BagItAssemblerService)
    • Si HSM indisponible : log WARNING, retourner null — réversibilité niveau standard conservée (INV-253-06, H-253-06)
    • Consomme HsmService.sign() existant (PD-37) — STUB tracé : // STUB: PD-37 — signature HSM export package
    • TSA optionnelle sur export.sig via TsaService.timestamp() — STUB tracé : // STUB: PD-39 — horodatage TSA sur export.sig
    • crypto.createVerify().update(hash) : INTERDIT si mode raw ECDSA (double-hash — learning PD-282)
  • Dependencies: [CC-253-08]

Niveau 4 (dépend des niveaux 1 et 2)

Tâche séquentielle unique — BulkExportService est le point d'orchestration synchrone.

  • Task 11 — CC-253-05 : BulkExportService + BulkExportController
  • Agent: agent-developer
  • Branche: feature/PD-253-l4-service-controller
  • Fichiers:
    • src/modules/bulk-export/services/bulk-export.service.ts
    • src/modules/bulk-export/controllers/bulk-export.controller.ts
  • Invariants couverts: INV-253-10 (audit fail-closed), INV-253-14 (atomicité sync/async), INV-253-07 (quota)
  • Endpoints REST:
    POST   /bulk-exports                        → 202 Accepted (BulkExportResponseDto)
    GET    /bulk-exports/:id                    → 200 (BulkExportResponseDto)
    GET    /bulk-exports/:id/download-url       → 200 (BulkExportDownloadUrlDto)
    POST   /bulk-exports/:id/confirm-download   → 200 (ECT-01)
    DELETE /bulk-exports/:id                    → 204
    
  • Contrats BulkExportService:
    • createExport(userId, dto): Promise<BulkExportResponseDto> — transaction DB unique : (1) vérification quota, (2) émission audit, (3) INSERT bulk_exports. Publication BullMQ post-commit (séparation INV-253-14).
    • getExport(userId, exportId): Promise<BulkExportResponseDto> — vérification ownership ou 403 FORBIDDEN_EXPORT_ACCESS
    • cancelExport(userId, exportId): Promise<void> — vérification ownership + état non-terminal. Si ASSEMBLING et ready_at non null (package déjà produit) : 409 avec état READY_FOR_DOWNLOAD inchangé
    • confirmDownload(userId, exportId): Promise<void> — délégation à ExportDownloadService
  • Pattern fail-closed audit (learning PD-85, INV-253-10):
    // CORRECT — fail-closed obligatoire
    await queryRunner.startTransaction();
    try {
      await this.auditService.log({ ... }); // throws si indisponible
      const saved = await queryRunner.manager.save(BulkExport, entity);
      await queryRunner.commitTransaction();
      await this.queue.add('assemble', { exportId: saved.id }); // post-commit
      return toDto(saved);
    } catch (err) {
      await queryRunner.rollbackTransaction();
      if (err instanceof AuditUnavailableException) throw new AuditWriteFailedException();
      throw err;
    }
    
  • Contrats BulkExportController:
    • OidcJwtAuthGuard obligatoire sur TOUS les endpoints — endpoint sans guard : INTERDIT
    • Vérification ownership sur tous les endpoints avec :id
    • 409 EXPORT_ALREADY_ACTIVE si quota dépassé à la création
    • 409 EXPORT_NOT_CANCELLABLE si annulation sur état terminal
    • Exposer package_s3_key dans une réponse : INTERDIT
    • ValidationPipe avec class-validator + class-transformer
  • Interdit:
    • .catch(() => logger.error()) sur appel audit — anti-catch-absorb (learning PD-85)
    • Publication BullMQ avant commitTransaction()
    • Math.random() pour tout identifiant
    • Rate limiting global (doit être par userId)
  • Dependencies: [CC-253-01, CC-253-03, CC-253-04]

Niveau 5 (dépend de tout)

Tâche séquentielle finale — le processor est le nœud intégrateur de tous les services.

  • Task 12 — CC-253-07 : BulkExportProcessor (BullMQ worker)
  • Agent: agent-developer
  • Branche: feature/PD-253-l5-processor
  • Fichiers:
    • src/modules/bulk-export/processors/bulk-export.processor.ts
  • Invariants couverts: INV-253-01 (exhaustivité), INV-253-02 (ProofEnvelope), INV-253-08 (pending anchor), INV-253-09 (soft-deleted/destroyed), INV-253-11 (chiffrement), INV-253-12 (no-residuals)
  • Contrats:
    • @Processor('pv-jobs-bulk-export') — nom de queue sans ':' (CC-253-02)
    • BulkExportJobPayload : { exportId: BulkExportId }
    • Séquence obligatoire:
    • ExportCleanupService.purgeStaleTemp(exportId)au démarrage avant tout traitement (learning PD-283)
    • Transition REQUESTED → ASSEMBLING + timestamp assembling_started_at
    • ExportScopeService.resolveDocuments(export) — liste documents éligibles
    • Pour chaque document : buildDocumentEntry() — ProofEnvelope absente → FAILED(PROOF_ENVELOPE_INCOMPLETE). Anchor pending → inclus tel quel (INV-253-08)
    • BagItAssemblerService.assemble(entries, export) — construction BagIt
    • DualManifestService.generate(packageDir) — dual manifest SHA-256 + SHA3-256
    • Si probative_level = 'strong' : ExportSigningService.signPackage() — optionnel
    • Upload S3 staging via S3Service (multipart obligatoire pour packages > 5GB via @aws-sdk/lib-storage Upload — H-253-11)
    • Transition ASSEMBLING → READY_FOR_DOWNLOAD + expires_at = now() + retention_ttl_h
    • finally { await ExportCleanupService.purgeStaleTemp(exportId) } — nettoyage (INV-253-12)
    • Timeout BullMQ = BULK_EXPORT_JOB_TIMEOUT_H * 3600 * 1000 ms → FAILED_TIMEOUT si dépassé
    • Audit à chaque transition : finally { await audit(transition) } — fail-closed (learning PD-85)
    • Répertoire temp : /tmp/bulk-export-{exportId}/
    • STUB notification disponibilité : // STUB: PC-253-03 — notification canal externe (email/push)
  • Interdit:
    • Déchiffrement du contenu des documents (Zero-Knowledge)
    • Math.random() pour tout identifiant de corrélation — crypto.randomUUID() uniquement
    • Fire-and-forget sur les transitions d'état
    • Accès à des documents d'un autre user_id
    • PutObjectCommand pour packages > 5GB — multipart obligatoire (H-253-11)
  • Dependencies: [CC-253-05, CC-253-06, CC-253-08, CC-253-09, CC-253-10, CC-253-11, CC-253-12]

4. Tâches additionnelles (tests + module)

Ces tâches constituent le niveau 6 et peuvent s'exécuter en parallèle dès que leurs dépendances respectives sont disponibles.

  • Task 13 — Tests unitaires BulkExportService
  • Agent: agent-qa-unit-integration
  • Branche: feature/PD-253-l6-tests-service
  • Fichiers:
    • src/modules/bulk-export/__tests__/bulk-export.service.spec.ts
  • Tests contractuels couverts: TC-ERR-04 (quota 409), TC-ERR-07 (audit fail-closed 500), TC-NOM-13 (annulation depuis REQUESTED), TC-NOM-14 (annulation depuis ASSEMBLING), TC-SEC-01 (accès inter-utilisateur 403)
  • Tests additionnels (non-contractuels): atomicité queryRunner (crash pre-commit → rollback), publication BullMQ post-commit uniquement
  • Traçabilité: chaque test contractuel porte l'identifiant TC-* dans son nom ou commentaire. Les tests additionnels portent // NON-CONTRACTUAL
  • Dependencies: [Task 11]

  • Task 14 — Tests unitaires ExportStateMachineService

  • Agent: agent-qa-unit-integration
  • Branche: feature/PD-253-l6-tests-statemachine
  • Fichiers:
    • src/modules/bulk-export/__tests__/export-state-machine.service.spec.ts
  • Tests contractuels couverts: TC-NOM-09 (matrice transitions autorisées + interdites), TC-NOM-10 (atomicité chaos), TC-NEG-06 (transition terminale FAILED→ASSEMBLING rejetée)
  • Tests additionnels: toutes les transitions de la matrice §5.4 — couverture exhaustive de la machine à états (chaque arc du DAG d'états)
  • Dependencies: [Task 5]

  • Task 15 — Tests E2E BulkExport endpoints

  • Agent: agent-qa-unit-integration
  • Branche: feature/PD-253-l6-tests-e2e
  • Fichiers:
    • test/bulk-export.e2e-spec.ts
  • Tests contractuels couverts:
    • TC-NOM-01 (Global nominal — exhaustivité READY_FOR_DOWNLOAD)
    • TC-NOM-02 (ProofEnvelope complète)
    • TC-NOM-03 (dual-hash + chaîne Merkle)
    • TC-NOM-04 (dual manifest présent)
    • TC-NOM-05 (niveau standard sans export.sig)
    • TC-NOM-07 (anchor pending conservé)
    • TC-NOM-08 (soft-deleted inclus, destroyed exclus + destruction-log.json)
    • TC-NOM-11 (expiration TTL → EXPIRED + purge)
    • TC-NOM-15 (confirm-download → DOWNLOADED — ajout Gate 5, ECT-01)
    • TC-ERR-01 (400 scope invalide)
    • TC-ERR-02 (401 non authentifié)
    • TC-ERR-03 (403 scope inter-tenant)
    • TC-ERR-05 (413 taille max dépassée)
    • TC-ERR-06 (ProofEnvelope incomplète → FAILED asynchrone)
    • TC-ERR-08 (500 assemblage échoué)
    • TC-ERR-09 (FAILED_TIMEOUT)
    • TC-ERR-10 (410 URL expirée)
    • TC-ERR-11 (410 export expiré)
    • TC-ERR-12 (corruption package détectée par manifest)
    • TC-INV-11 (chiffrement artefacts temporaires)
    • TC-INV-12 (no-residuals succès/échec/crash)
    • TC-SEC-01 (403 FORBIDDEN_EXPORT_ACCESS inter-utilisateur)
    • TC-NEG-01 à TC-NEG-08 (tests négatifs adversariaux)
  • Note: TC-NOM-12 (performance P95) conditionnel à la disponibilité d'un environnement de référence instrumenté (H-253-04 — non bloquant).
  • Dependencies: [Task 11, Task 12]

  • Task 16 — BulkExportModule (barrel + registration)

  • Agent: agent-developer
  • Branche: feature/PD-253-l6-module-barrel
  • Fichiers:
    • src/modules/bulk-export/bulk-export.module.ts
    • src/modules/bulk-export/index.ts
  • Contrats:
    • Déclaration NestJS de tous les providers, controllers, imports (BullModule, TypeOrmModule, AuditModule, StorageModule)
    • Enregistrement de la queue BullMQ pv-jobs-bulk-export via BullModule.registerQueue({ name: 'pv-jobs-bulk-export' })
    • Export des services nécessaires aux modules consommateurs
    • Registration dans AppModule via imports
  • Dependencies: [All tasks 1-12]

5. Bloc parallelization (YAML)

parallelization:
  story: PD-253
  strategy: by_level
  levels:
    - level: 0
      name: "Fondations"
      description: "Entité DB, migration DDL, configuration SLA  socle de tous les services"
      tasks: [1, 2]
      task_names:
        - "CC-253-01 : Entité BulkExport + migration DDL"
        - "CC-253-02 : Configuration BulkExportModule"
      agents: [agent-config, agent-config]
      branches:
        - feature/PD-253-l0-entities-migration
        - feature/PD-253-l0-config
      estimated_time: "1h"
      blocking_for: [1, 2, 3, 4, 5, 6, 7]

    - level: 1
      name: "DTOs et périmètre de sélection"
      description: "DTOs API + ExportScopeService  interfaces publiques et résolution des 4 granularités"
      tasks: [3, 4]
      task_names:
        - "CC-253-03 : DTOs requête/réponse"
        - "CC-253-06 : ExportScopeService"
      agents: [agent-developer, agent-developer]
      branches:
        - feature/PD-253-l1-dtos
        - feature/PD-253-l1-export-scope
      estimated_time: "1.5h"
      blocking_for: [8, 9]

    - level: 2
      name: "Services cœur"
      description: "Quota, machine à états, BagIt, manifests, destruction-log, cleanup  4 agents en parallèle"
      tasks: [5, 6, 7, 8]
      task_names:
        - "CC-253-04 : ExportQuotaService + ExportStateMachineService"
        - "CC-253-08 : BagItAssemblerService + DualManifestService"
        - "CC-253-09 : DestructionLogService"
        - "CC-253-12 : ExportCleanupService + ExportExpiryScheduler"
      agents: [agent-developer, agent-developer, agent-developer, agent-developer]
      branches:
        - feature/PD-253-l2-quota-statemachine
        - feature/PD-253-l2-bagit-manifest
        - feature/PD-253-l2-destruction-log
        - feature/PD-253-l2-cleanup-scheduler
      estimated_time: "2h"
      blocking_for: [9, 10, 11]

    - level: 3
      name: "Services dépendants"
      description: "ExportDownloadService (confirm-download ECT-01) + ExportSigningService (niveau strong)"
      tasks: [9, 10]
      task_names:
        - "CC-253-10 : ExportDownloadService"
        - "CC-253-11 : ExportSigningService"
      agents: [agent-developer, agent-developer]
      branches:
        - feature/PD-253-l3-export-download
        - feature/PD-253-l3-export-signing
      estimated_time: "1.5h"
      blocking_for: [11]

    - level: 4
      name: "Orchestration synchrone"
      description: "BulkExportService + BulkExportController  point d'entrée REST et pattern fail-closed audit"
      tasks: [11]
      task_names:
        - "CC-253-05 : BulkExportService + BulkExportController"
      agents: [agent-developer]
      branches:
        - feature/PD-253-l4-service-controller
      estimated_time: "2h"
      blocking_for: [12]

    - level: 5
      name: "Processor asynchrone"
      description: "BulkExportProcessor  nœud intégrateur BullMQ, orchestration complète de l'assemblage"
      tasks: [12]
      task_names:
        - "CC-253-07 : BulkExportProcessor"
      agents: [agent-developer]
      branches:
        - feature/PD-253-l5-processor
      estimated_time: "2h"
      blocking_for: [13, 14, 15]

    - level: 6
      name: "Tests + module barrel"
      description: "Tests unitaires, E2E et module NestJS final  4 agents en parallèle"
      tasks: [13, 14, 15, 16]
      task_names:
        - "Tests unitaires BulkExportService"
        - "Tests unitaires ExportStateMachineService"
        - "Tests E2E BulkExport endpoints"
        - "BulkExportModule barrel + registration"
      agents:
        [
          agent-qa-unit-integration,
          agent-qa-unit-integration,
          agent-qa-unit-integration,
          agent-developer,
        ]
      branches:
        - feature/PD-253-l6-tests-service
        - feature/PD-253-l6-tests-statemachine
        - feature/PD-253-l6-tests-e2e
        - feature/PD-253-l6-module-barrel
      estimated_time: "3h"

  total_sequential_time: "20h"
  total_parallel_time: "13h"
  speedup_factor: 1.54
  git_strategy: branch_per_level
  merge_target: develop
  merge_order: "level-by-level  merge suivant uniquement après CI vert sur level précédent"

6. Contraintes techniques rappelées

Les contraintes suivantes sont OBLIGATOIRES pour tous les agents. Elles découlent des learnings des REX précédents et sont non négociables.

1. S3 multipart (H-253-11)

BulkExportProcessor (CC-253-07) DOIT utiliser @aws-sdk/lib-storage Upload pour les packages > 5 GB, PAS PutObjectCommand. La limite PutObjectCommand est 5 GB (S3 spec) ; les exports peuvent atteindre 100 GB. Si S3Service.uploadFile() est un stub existant, il DOIT être étendu pour déléguer l'upload multipart. Criticité : MAJEUR (bloque les exports de gros coffres).

// CORRECT — multipart obligatoire pour > 5GB
import { Upload } from '@aws-sdk/lib-storage';
const upload = new Upload({
  client: this.s3Client,
  params: { Bucket: stagingBucket, Key: packageS3Key, Body: fileStream },
});
await upload.done();

2. failure_reason en VARCHAR + CHECK (ECT-03)

La colonne failure_reason est VARCHAR(50) avec contrainte CHECK (failure_reason IN (...) OR failure_reason IS NULL). Cette stratégie évite le piège ALTER TYPE ADD VALUE + commitTransaction() obligatoire de PostgreSQL (learning PD-282, PD-279). Aucun ALTER TYPE ADD VALUE ne doit apparaître dans la migration.

3. Branded types obligatoires pour UUID (learning PD-282)

BulkExportId et UserId sont des branded types nominaux. Tout service qui accepte un exportId DOIT typer le paramètre en BulkExportId, pas en string. Inversion silencieuse impossible au compile-time.

type BulkExportId = string & { readonly __brand: 'BulkExportId' };
type UserId = string & { readonly __brand: 'UserId' };

4. purgeStale() au démarrage du processor (learning PD-283)

ExportCleanupService.purgeStaleTemp(exportId) DOIT être appelé AU DÉMARRAGE du processor (avant tout traitement), ET dans le bloc finally. Un crash applicatif (OOM, kill signal) bypass le finally — sans purge au démarrage, les résidus du run précédent persistent jusqu'au prochain run.

// CORRECT — double nettoyage
async process(job: Job<BulkExportJobPayload>) {
  await this.cleanupService.purgeStaleTemp(job.data.exportId); // démarrage
  try {
    // ... traitement ...
  } finally {
    await this.cleanupService.purgeStaleTemp(job.data.exportId); // toujours
  }
}

5. Anti-catch-absorb — audit fail-closed (learning PD-85)

Tout appel auditService.log() dans le module bulk-export est en dehors de tout catch absorbant. Pattern obligatoire : finally { await audit() } ou re-throw après audit.

// INTERDIT — absorbe l'échec d'audit
await this.auditService.log(event).catch((err) => this.logger.error(err));

// CORRECT — fail-closed (propagation de l'erreur)
await this.auditService.log(event); // throws → 500 AUDIT_WRITE_FAILED

6. confirm-download endpoint (ECT-01)

POST /bulk-exports/:id/confirm-download est le seul déclencheur de la transition READY_FOR_DOWNLOAD → DOWNLOADED. Ce mécanisme est retenu car l'architecture S3 présignée ne fournit pas de webhook fiable de téléchargement réussi. L'endpoint est idempotent : si déjà DOWNLOADED, retourne 200 sans re-audit.

7. destruction-log.json — 6 champs obligatoires (ECT-02)

Chaque entrée de destruction-log.json DOIT contenir exactement ces 6 champs : document_id, destroyed_at, destruction_act_hash, destruction_batch_id, destruction_reason, bordereau_id. Omettre bordereau_id est une violation de INV-250-03 (PD-250). La validation fonctionnelle de destruction_act_hash (regex + existence en DB) est obligatoire — la validation de format seul est insuffisante (learning PD-283).

8. crypto.randomUUID() obligatoire (learning PD-63)

Tous les identifiants générés utilisent import { randomUUID } from 'node:crypto'. Math.random() est flaggé Sonar S2245.

9. Validation fonctionnelle des champs de sécurité (learning PD-283)

Format != fonctionnel. Les champs plaintext_hash, ciphertext_hash, destruction_act_hash et package_s3_key doivent être validés DEUX fois : - Validation format (regex ^[a-f0-9]{64}$ ou UUID v4) - Validation fonctionnelle (référencé dans Merkle pour ciphertext_hash, existence en DB pour destruction_act_hash, existence S3 pour package_s3_key)

10. path.posix.join() pour les chemins manifests (CC-253-08)

path.join() utilise le séparateur OS (\ sur Windows). Les manifests BagIt DOIVENT utiliser / comme séparateur. Utiliser path.posix.join() uniquement pour construire les chemins relatifs dans les fichiers manifest-sha256.txt et manifest-sha3.txt.

11. BullMQ v5 — API dépréciée (learning PD-55)

Ne JAMAIS utiliser getRepeatableJobs() / removeRepeatableByKey() (deprecated BullMQ v5). Utiliser getJobSchedulers() / removeJobScheduler(). Nom de queue sans ':' obligatoire.


Annexe — Mapping Tests contractuels → Tâches agents

Test ID Invariant couvert Tâche responsable
TC-NOM-01 INV-253-01 Task 12 (Processor — exhaustivité), Task 15 (E2E)
TC-NOM-02 INV-253-02 Task 6 (BagIt — ProofEnvelope), Task 15 (E2E)
TC-NOM-03 INV-253-03 Task 6 (dual-hash + Merkle), Task 15 (E2E)
TC-NOM-04 INV-253-04 Task 6 (DualManifest), Task 15 (E2E)
TC-NOM-05 INV-253-06 Task 10 (ExportSigningService), Task 15 (E2E)
TC-NOM-06 INV-253-06 Task 10 (ExportSigningService), Task 15 (E2E)
TC-NOM-07 INV-253-08 Task 6 (BagIt — pending anchor), Task 15 (E2E)
TC-NOM-08 INV-253-09 Task 4 (ExportScope), Task 7 (DestructionLog), Task 15 (E2E)
TC-NOM-09 INV-253-13 Task 5 (StateMachine), Task 14 (tests unitaires)
TC-NOM-10 INV-253-14 Task 11 (BulkExportService — atomicité), Task 13 (tests unitaires)
TC-NOM-11 CA-253-09 Task 8 (ExportExpiryScheduler), Task 15 (E2E)
TC-NOM-12 CA-253-12 Task 12 (Processor — async isolé) — conditionnel env P95
TC-NOM-13 INV-253-13 Task 11 (cancelExport), Task 13 (tests unitaires)
TC-NOM-14 INV-253-13 Task 11 (cancelExport — idempotence ASSEMBLING), Task 13
TC-NOM-15 ECT-01 Task 9 (ExportDownloadService), Task 15 (E2E)
TC-SEC-01 §5.7 accès inter-user Task 11 (ownership check), Task 13 (tests unitaires)
TC-ERR-04 INV-253-07 Task 5 (ExportQuotaService), Task 13 (tests unitaires)
TC-ERR-07 INV-253-10 Task 11 (audit fail-closed), Task 13 (tests unitaires)
TC-ERR-12 INV-253-05 Task 6 (DualManifest.verify), Task 15 (E2E)
TC-INV-11 INV-253-11 Task 12 (Processor — Zero-Knowledge), Task 15 (E2E)
TC-INV-12 INV-253-12 Task 8 (ExportCleanup), Task 12 (Processor — purgeStale), Task 15

Références

  • Spec : PD-253-specification.md (v2)
  • Tests : PD-253-tests.md (v2)
  • Plan : PD-253-plan.md (v1.0, Gate 5 approuvé)
  • Gate 3 : PD-253-verdict-step3-v2.yaml (RESERVE — ECT-01/02/03 adressés dans le plan)
  • Gate 5 : PD-253-verdict-step5-v1.yaml
  • Dépendances : PD-85 (ExportEngine), PD-282 (ProofEnvelope eIDAS), PD-39 (TSA), PD-37 (HSM), PD-250 (destruction bordereaux)
  • Standards : NF Z42-013:2020 §13.1, ISO 14641, RGPD Art.20, BagIt RFC 8493
  • Learnings injectés : PD-83, PD-85, PD-250, PD-262, PD-265, PD-279, PD-282, PD-283