Aller au contenu

PD-253 — Plan d'implémentation : Export bulk avec métadonnées et preuves

Story : PD-253 Auteur : Claude (architecte senior ProbatioVault) Date : 2026-03-12 Version : v1.0 Statut : Soumis Gate 5


1. Vue d'ensemble architecture

1.1 Périmètre modulaire

PD-253 introduit un nouveau module NestJS BulkExportModule dans src/modules/bulk-export/. Il est distinct du module export/ existant (PD-85, export synchrone dossier plainte). Le choix d'un module séparé est justifié par :

  • L'export bulk est asynchrone (BullMQ worker), là où PD-85 est synchrone.
  • Les SLA, la machine à états, et la persistance DB sont incompatibles avec l'architecture PD-85.
  • Cela évite toute régression du module PD-85 en cours d'exploitation.

1.2 Modules NestJS touchés

Module Rôle dans PD-253 Modification
bulk-export Nouveau module principal Création complète
audit Émission événements fail-closed Extension AuditActionType uniquement
storage Upload/download package staging S3 Consommation existante (S3Service)
legal-pre Lecture ProofEnvelope (LegalCompositeProof) Lecture seule, aucune modification
destruction Requête statut destruction légale (PD-250) Lecture seule via requête DB
documents Sélection périmètre, métadonnées DocumentSecure Lecture seule via repository
tsa Consommation token TSA (PD-39) Aucune modification
jobs Infrastructure BullMQ existante Extension avec nouvelle queue

1.3 Flux de données global

POST /bulk-exports
BulkExportController
        │── validation DTO (scope, filtres, UUID)
        │── vérification quota (1 actif max — synchrone DB)
        │── audit fail-closed (AuditService)
        │── INSERT bulk_exports (REQUESTED)
        │── post-commit: publish BullMQ job
BulkExportProcessor (worker async)
        │── REQUESTED → ASSEMBLING
        │── purgeStale() au démarrage (INV-253-12)
        │── sélection périmètre (DocumentSecure + statut destruction)
        │── pour chaque document : ProofEnvelope + métadonnées
        │── génération BagIt (data/ + metadata/ + destruction-log.json)
        │── dual manifest (SHA-256 + SHA3-256)
        │── [optionnel] signature HSM export.sig
        │── upload package S3 staging
        │── ASSEMBLING → READY_FOR_DOWNLOAD
        │── notification (événement)
GET /bulk-exports/:id
        │── vérification ownership (user_id)
        │── réponse avec status + failure_reason si FAILED*
GET /bulk-exports/:id/download-url
        │── vérification ownership
        │── génération URL signée TTL
        │── trigger READY_FOR_DOWNLOAD → DOWNLOADED (via webhook S3 ou callback explicite)
DELETE /bulk-exports/:id
        │── vérification ownership
        │── annulation si REQUESTED | ASSEMBLING

1.4 Arborescence cible

src/modules/bulk-export/
  bulk-export.module.ts
  config/
    bulk-export.config.ts
  controllers/
    bulk-export.controller.ts
  dto/
    create-bulk-export.dto.ts
    bulk-export-response.dto.ts
    bulk-export-download-url.dto.ts
  entities/
    bulk-export.entity.ts
  enums/
    bulk-export-status.enum.ts
    bulk-export-scope.enum.ts
    bulk-export-failure-reason.enum.ts
  exceptions/
    bulk-export.exceptions.ts
  interfaces/
    bagit-package.interfaces.ts
    proof-envelope-export.interfaces.ts
    destruction-log.interfaces.ts
  processors/
    bulk-export.processor.ts
  services/
    bulk-export.service.ts          (orchestration, quota, création)
    bagit-assembler.service.ts      (construction package BagIt)
    dual-manifest.service.ts        (SHA-256 + SHA3-256 manifests)
    destruction-log.service.ts      (construction destruction-log.json)
    export-scope.service.ts         (sélection périmètre 4 granularités)
    export-quota.service.ts         (vérification 1 actif/user)
    export-cleanup.service.ts       (purgeStale, expiration, TTL)
    export-signing.service.ts       (signature HSM optionnelle export.sig)
    export-download.service.ts      (URL signée + transition DOWNLOADED)
  schedulers/
    export-expiry.scheduler.ts      (READY_FOR_DOWNLOAD → EXPIRED)
  __tests__/
src/database/migrations/
  1742000000000-PD253-CreateBulkExports.ts

2. Résolution des réserves Gate 3

ECT-01 — Mécanisme de confirmation de téléchargement (READY_FOR_DOWNLOAD → DOWNLOADED)

Décision technique retenue : Endpoint explicite de confirmation côté backend.

Justification : L'architecture S3 présignée ne fournit pas de webhook fiable de téléchargement réussi. Un callback HTTP 200 S3 n'est pas observable depuis le backend sans CloudWatch + Lambda (hors périmètre). L'approche la plus simple et déterministe est un endpoint POST /bulk-exports/:id/confirm-download appelé par le client après avoir reçu le fichier intégralement.

Contrat : - Endpoint : POST /bulk-exports/:id/confirm-download - Garde : OidcJwtAuthGuard + vérification user_id - Précondition : état READY_FOR_DOWNLOAD uniquement - Effet : transition READY_FOR_DOWNLOAD → DOWNLOADED + audit WORM - Idempotent : si déjà DOWNLOADED, renvoie 200 sans erreur (pas de double audit) - Si état != READY_FOR_DOWNLOAD et != DOWNLOADED : 409 EXPORT_NOT_DOWNLOADABLE

Test associé : TC-NOM-15 (à ajouter en Gate 5) — Given export READY_FOR_DOWNLOAD, When POST /confirm-download, Then état DOWNLOADED, audit émis.

Lien INV-253-13 : la machine à états explicite inclut ce trigger comme seul déclencheur de la transition READY_FOR_DOWNLOAD → DOWNLOADED.


ECT-02 — Schéma complet de destruction-log.json

Schéma contractualisé :

interface DestructionLogEntry {
  document_id: string;           // UUID v4, identifiant technique du document
  destroyed_at: string;          // RFC3339 UTC, date de destruction légale (PD-250)
  destruction_act_hash: string;  // hex SHA3-256 (64 chars) de l'acte de destruction
  destruction_batch_id: string;  // UUID v4, référence au batch PD-250
  destruction_reason: string;    // Valeur parmi: "RETENTION_EXPIRED" | "LEGAL_REQUEST" | "USER_REQUEST"
  bordereau_id: string;          // UUID v4, référence au bordereau PD-250 (INV-250-03)
}

interface DestructionLog {
  schema_version: "1.0";         // Version du schéma pour évolution future
  export_id: string;             // UUID v4 de l'export PD-253
  generated_at: string;          // RFC3339 UTC
  total_destroyed: number;       // Comptage des documents détruits exclus
  entries: DestructionLogEntry[];
}

Source des données : requête DB sur destruction_bordereaux (entité PD-250) filtrée par user_id + document_id dans le périmètre d'export. Le destruction_act_hash est consommé depuis la table bordereau (INV-250-03).

Encodage : UTF-8, sans BOM. Chemin dans package : metadata/destruction-log.json.


ECT-03 — Modélisation failure_reason

Décision : colonne failure_reason de type VARCHAR(50) nullable dans bulk_exports, exposée dans la réponse GET /bulk-exports/:id.

Enum contractuel :

export enum BulkExportFailureReason {
  PROOF_ENVELOPE_INCOMPLETE = 'PROOF_ENVELOPE_INCOMPLETE',  // ProofEnvelope absente ou invalide
  PACKAGE_SIZE_EXCEEDED     = 'PACKAGE_SIZE_EXCEEDED',      // > 100 GB détecté à l'assemblage
  STORAGE_UPLOAD_FAILED     = 'STORAGE_UPLOAD_FAILED',      // Échec upload S3 staging
  HSM_SIGNATURE_FAILED      = 'HSM_SIGNATURE_FAILED',       // Échec signature optionnelle (niveau strong)
  SCOPE_RESOLUTION_FAILED   = 'SCOPE_RESOLUTION_FAILED',    // Erreur résolution périmètre documents
  UNKNOWN_ASSEMBLY_ERROR    = 'UNKNOWN_ASSEMBLY_ERROR',      // Erreur technique non classifiée
}

Emplacement API : champ failure_reason dans la réponse BulkExportResponseDto (nullable, présent uniquement si status ∈ {FAILED, FAILED_TIMEOUT}).

Validation fonctionnelle : le champ est défini en DB comme VARCHAR(50) avec contrainte CHECK sur les valeurs de l'enum. Le DTO de réponse le sérialise directement. Les agents n'utilisent jamais de string libre — toujours la valeur enum typée.


ECT-05 — Politique de purge post-téléchargement

Décision : la rétention est indépendante du téléchargement. Un export DOWNLOADED reste accessible (objet S3 présent) jusqu'à expiration du TTL package_retention_ttl (défaut 168h / 7j). La purge anticipée n'est pas implémentée dans PD-253 (hors périmètre, complexité vs bénéfice faible).

Justification : évite un cas de bord où le client télécharge mais la connexion est coupée à 99%, déclenchant une purge prématurée. La rétention uniforme simplifie le code de nettoyage.

Comportement : - État DOWNLOADED : accès URL signée toujours possible jusqu'à expiration TTL. - À expiration du TTL : ExportExpiryScheduler purge l'objet S3 si présent. DOWNLOADED reste terminal (spec §5.4 DOWNLOADED → * : INTERDITE). Seul READY_FOR_DOWNLOAD transite vers EXPIRED. L'état DOWNLOADED est conservé en DB après purge physique S3 (correction E-02). - Pour READY_FOR_DOWNLOAD non téléchargé : transition → EXPIRED + purge S3.

Hypothèse H-253-07 (ajout plan) : la rétention uniforme post-téléchargement est acceptable par défaut. Une purge anticipée sur confirmation téléchargement pourra être ajoutée dans une story ultérieure si requis par RGPD / optimisation stockage.


ECT-06 — Reprise après FAILED_TIMEOUT (libération quota)

Décision : La libération du quota est immédiate à la transition vers tout état terminal.

Mécanisme : La définition de "export actif" est status IN ('REQUESTED', 'ASSEMBLING', 'READY_FOR_DOWNLOAD'). Dès qu'un export passe en FAILED, FAILED_TIMEOUT, CANCELLED, EXPIRED, ou DOWNLOADED, il quitte le comptage du quota. La vérification de quota dans ExportQuotaService utilise une requête COUNT(*) WHERE user_id = ? AND status IN (...) — aucun mécanisme de libération explicite nécessaire.

Reprise utilisateur après FAILED_TIMEOUT : manuelle uniquement. L'utilisateur soumet une nouvelle demande. Aucune relance automatique (risque de boucle infinie sur infrastructure défaillante). L'état FAILED_TIMEOUT est terminal, comme décrit dans la spec §5.4.

Implémentation : ExportQuotaService.hasActiveExport(userId) fait un COUNT synchrone dans la transaction de création. La libération est implicite (pas de colonne de slot).


ZA-01 — Libération quota (zones d'ombre)

Traitée par ECT-06 ci-dessus. La libération est immédiate et implicite (pas de batch différé).

ZA-02 — Code HTTP 504 incohérent avec l'async

Décision : le code 504 est supprimé de la couche HTTP synchrone. La spec §6 liste 504 EXPORT_TIMEOUT mais ce code n'est jamais émis en réponse directe. Un client qui interroge GET /bulk-exports/:id sur un export FAILED_TIMEOUT reçoit 200 OK avec { status: "FAILED_TIMEOUT" }. Le code 504 est retiré de l'enum HTTP de la spec et remplacé dans les commentaires par "état final FAILED_TIMEOUT observable via GET".

ZA-03 — Snapshot sémantique au moment REQUESTED

Décision : La sélection du périmètre (quels documents inclure) est effectuée au moment du passage REQUESTED → ASSEMBLING, c'est-à-dire au démarrage du worker. Un document créé entre REQUESTED et ASSEMBLING n'est pas inclus (fenêtre de quelques secondes, acceptable). La spec est silencieuse : on documente ce comportement dans l'hypothèse H-253-08.

ZA-04 — SELECTION avec document_ids inexistants

Décision : Si tous les document_ids du scope SELECTION n'appartiennent pas à l'utilisateur ou n'existent pas, l'export passe en FAILED avec failure_reason: SCOPE_RESOLUTION_FAILED. Si certains seulement sont invalides, ils sont ignorés et l'export continue avec les valides (comportement cohérent avec le rejet partiel de PD-85).


3. Modèle de données

3.1 Nouvelle table : vault_secure.bulk_exports

CREATE TABLE vault_secure.bulk_exports (
  id                    UUID        NOT NULL DEFAULT gen_random_uuid(),
  user_id               UUID        NOT NULL,
  status                VARCHAR(30) NOT NULL DEFAULT 'REQUESTED',
  scope                 VARCHAR(20) NOT NULL,           -- GLOBAL|VAULT|PERIOD|SELECTION
  scope_params          JSONB       NOT NULL DEFAULT '{}',
  probative_level       VARCHAR(10) NOT NULL DEFAULT 'standard', -- standard|strong
  failure_reason        VARCHAR(50),                    -- BulkExportFailureReason enum
  package_s3_key        VARCHAR(512),                   -- chemin S3 staging (nullable jusqu'à READY)
  package_size_bytes    BIGINT,                         -- taille calculée à l'assemblage
  download_url_ttl_h    INTEGER     NOT NULL DEFAULT 24,
  retention_ttl_h       INTEGER     NOT NULL DEFAULT 168,
  expires_at            TIMESTAMPTZ,                    -- calculé à READY_FOR_DOWNLOAD
  requested_at          TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  assembling_started_at TIMESTAMPTZ,
  ready_at              TIMESTAMPTZ,
  downloaded_at         TIMESTAMPTZ,
  failed_at             TIMESTAMPTZ,
  expired_at            TIMESTAMPTZ,
  cancelled_at          TIMESTAMPTZ,

  CONSTRAINT bulk_exports_pkey PRIMARY KEY (id),
  CONSTRAINT bulk_exports_status_check CHECK (
    status IN (
      'REQUESTED','ASSEMBLING','READY_FOR_DOWNLOAD',
      'DOWNLOADED','EXPIRED','FAILED','FAILED_TIMEOUT','CANCELLED'
    )
  ),
  CONSTRAINT bulk_exports_scope_check CHECK (
    scope IN ('GLOBAL','VAULT','PERIOD','SELECTION')
  ),
  CONSTRAINT bulk_exports_failure_reason_check CHECK (
    failure_reason IN (
      'PROOF_ENVELOPE_INCOMPLETE','PACKAGE_SIZE_EXCEEDED',
      'STORAGE_UPLOAD_FAILED','HSM_SIGNATURE_FAILED',
      'SCOPE_RESOLUTION_FAILED','UNKNOWN_ASSEMBLY_ERROR'
    ) OR failure_reason IS NULL
  )
);

CREATE INDEX idx_bulk_exports_user_id        ON vault_secure.bulk_exports (user_id);
CREATE INDEX idx_bulk_exports_status         ON vault_secure.bulk_exports (status);
CREATE INDEX idx_bulk_exports_user_status    ON vault_secure.bulk_exports (user_id, status)
  WHERE status IN ('REQUESTED', 'ASSEMBLING', 'READY_FOR_DOWNLOAD');
CREATE INDEX idx_bulk_exports_expires_at     ON vault_secure.bulk_exports (expires_at)
  WHERE status = 'READY_FOR_DOWNLOAD';

Notes TypeORM : - L'entité BulkExport est dans src/modules/bulk-export/entities/bulk-export.entity.ts. - scope_params est JSONB pour stocker { vault_id?, from?, to?, document_ids? } selon le scope. - Aucune migration ne modifie de colonne existante (spec §5.8 : stratégie DDL non applicable aux tables existantes).

3.2 Entité TypeORM

// Branded types (learning REX)
type BulkExportId = string & { readonly __brand: 'BulkExportId' };
type UserId = string & { readonly __brand: 'UserId' };

@Entity('bulk_exports', { schema: 'vault_secure' })
export class BulkExport {
  @PrimaryGeneratedColumn('uuid')
  id!: BulkExportId;

  @Column('uuid', { name: 'user_id' })
  @Index()
  userId!: UserId;

  @Column('varchar', { name: 'status', length: 30, default: 'REQUESTED' })
  status!: BulkExportStatus;

  @Column('varchar', { name: 'scope', length: 20 })
  scope!: BulkExportScope;

  @Column('jsonb', { name: 'scope_params', default: '{}' })
  scopeParams!: BulkExportScopeParams;

  @Column('varchar', { name: 'probative_level', length: 10, default: 'standard' })
  probativeLevel!: 'standard' | 'strong';

  @Column('varchar', { name: 'failure_reason', length: 50, nullable: true })
  failureReason!: BulkExportFailureReason | null;

  @Column('varchar', { name: 'package_s3_key', length: 512, nullable: true })
  packageS3Key!: string | null;

  @Column('bigint', { name: 'package_size_bytes', nullable: true })
  packageSizeBytes!: number | null;

  @Column('integer', { name: 'download_url_ttl_h', default: 24 })
  downloadUrlTtlH!: number;

  @Column('integer', { name: 'retention_ttl_h', default: 168 })
  retentionTtlH!: number;

  @Column('timestamptz', { name: 'expires_at', nullable: true })
  expiresAt!: Date | null;

  @CreateDateColumn({ name: 'requested_at', type: 'timestamptz' })
  requestedAt!: Date;

  @Column('timestamptz', { name: 'assembling_started_at', nullable: true })
  assemblingStartedAt!: Date | null;

  @Column('timestamptz', { name: 'ready_at', nullable: true })
  readyAt!: Date | null;

  @Column('timestamptz', { name: 'downloaded_at', nullable: true })
  downloadedAt!: Date | null;

  @Column('timestamptz', { name: 'failed_at', nullable: true })
  failedAt!: Date | null;

  @Column('timestamptz', { name: 'expired_at', nullable: true })
  expiredAt!: Date | null;

  @Column('timestamptz', { name: 'cancelled_at', nullable: true })
  cancelledAt!: Date | null;
}

3.3 Extension audit

Ajout dans src/modules/audit/types/audit-action.types.ts :

// PD-253 — Export bulk
BULK_EXPORT_CREATED       = 'bulk_export.created',
BULK_EXPORT_ASSEMBLING    = 'bulk_export.assembling',
BULK_EXPORT_READY         = 'bulk_export.ready',
BULK_EXPORT_DOWNLOADED    = 'bulk_export.downloaded',
BULK_EXPORT_FAILED        = 'bulk_export.failed',
BULK_EXPORT_FAILED_TIMEOUT = 'bulk_export.failed_timeout',
BULK_EXPORT_CANCELLED     = 'bulk_export.cancelled',
BULK_EXPORT_EXPIRED       = 'bulk_export.expired',

4. Contrats de code (CC-253-01 à CC-253-12)

CC-253-01 — Entité, migration, enums

Module : bulk-export-entity Agent : agent-config INV couverts : INV-253-13 (contrainte CHECK état), INV-253-14 (colonne atomique)

Interfaces : - BulkExport — entité TypeORM - BulkExportStatus — enum 8 états - BulkExportScope — enum 4 granularités - BulkExportFailureReason — enum 6 raisons (ECT-03) - BulkExportScopeParams — interface JSONB (vault_id?, from?, to?, document_ids?)

Invariants : - Contrainte CHECK PostgreSQL sur status et failure_reason (liste fermée) - Migration idempotente : CREATE TABLE IF NOT EXISTS - Branded types pour BulkExportId et UserId - Aucune modification de table existante

Interdit : - Math.random() pour tout identifiant - String libre pour failure_reason (enum uniquement) - Migration qui altère une colonne existante

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


CC-253-02 — Configuration et constantes

Module : bulk-export-config Agent : agent-config INV couverts : INV-253-07 (quota), SLA §5.⅖.3

Interfaces : - BulkExportConfig — interface typée

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 ':')

Invariants : - Rejet strict (pas de clamp) si hors bornes — Joi.number().min(x).max(y) sans .default(clamp()) - allowUnknown: false, abortEarly: false - Nom de queue sans ':' (learning BullMQ v5 PD-55)

Interdit : - Valeurs codées en dur — toujours via ConfigService - Nom de queue avec ':' - getRepeatableJobs() / removeRepeatableByKey() (deprecated BullMQ v5)

Fichiers : - src/modules/bulk-export/config/bulk-export.config.ts - src/config/config.schema.ts (extension Joi)


CC-253-03 — DTOs requête/réponse

Module : bulk-export-dto Agent : agent-developer INV couverts : INV-253-07 (validation scope), INV-253-13 (réponse status)

Interfaces : - CreateBulkExportDto — corps POST, class-validator - BulkExportResponseDto — réponse GET status (inclut failure_reason nullable) - BulkExportDownloadUrlDto — réponse GET download-url (url, expiresAt)

Invariants : - scope : @IsEnum(BulkExportScope) — case-sensitive, rejet si minuscule - from / to : @IsISO8601() + validation from <= to dans le service - document_ids si scope SELECTION : @IsArray @ArrayMinSize(1) @IsUUID('4', {each: true}) @ArrayUnique() - failure_reason dans BulkExportResponseDto : nullable, type BulkExportFailureReason | null - Aucun champ sensible exposé (pas de package_s3_key interne)

Interdit : - Exposer package_s3_key dans la réponse API (chemin S3 interne) - Accepter scope en minuscule sans rejet 400 - String libre pour failure_reason

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/bulk-export-download-url.dto.ts


CC-253-04 — Service de quota et machine à états

Module : bulk-export-quota-state Agent : agent-developer INV couverts : INV-253-07 (quota 1 actif), INV-253-13 (transitions), INV-253-14 (atomicité)

Interfaces : - ExportQuotaService : hasActiveExport(userId: UserId): Promise<boolean> - ExportStateMachineService : transition(export: BulkExport, to: BulkExportStatus): void (throws si interdit)

Invariants : - hasActiveExport : COUNT(*) WHERE user_id = ? AND status IN ('REQUESTED','ASSEMBLING','READY_FOR_DOWNLOAD') — lecture seule, exécutée dans la transaction de création - La machine à états valide chaque transition contre la matrice spec §5.4 avant tout UPDATE - Toute transition interdite lève BulkExportTransitionException avec état source + destination - Les états terminaux (DOWNLOADED, EXPIRED, FAILED, FAILED_TIMEOUT, CANCELLED) ont un guard isTerminal() qui bloque toute transition sortante - La libération de quota est implicite (COUNT filtre les terminaux)

Interdit : - Transition directe REQUESTED → READY_FOR_DOWNLOAD (doit passer par ASSEMBLING) - Tout UPDATE de statut sans passage par ExportStateMachineService - Fire-and-forget sur les transitions d'état

Fichiers : - src/modules/bulk-export/services/export-quota.service.ts - src/modules/bulk-export/services/bulk-export-state-machine.service.ts


CC-253-05 — Service orchestration (création + annulation)

Module : bulk-export-service Agent : agent-developer INV couverts : INV-253-10 (audit fail-closed), INV-253-14 (atomicité sync/async)

Interfaces : - BulkExportService : - createExport(userId, dto): Promise<BulkExportResponseDto> - getExport(userId, exportId): Promise<BulkExportResponseDto> - cancelExport(userId, exportId): Promise<void> - confirmDownload(userId, exportId): Promise<void> (ECT-01)

Invariants : - createExport : transaction DB unique contenant (1) vérification quota, (2) émission audit, (3) INSERT bulk_exports. Si l'audit échoue, la transaction est annulée et 500 AUDIT_WRITE_FAILED est renvoyé. Pattern : try { await audit(); await repo.save(export); } catch { throw new AuditWriteFailedException(); }pas de catch-absorb (learning PD-85). - Publication BullMQ post-commit : queryRunner.commitTransaction() puis this.queue.add(...) — séparation contractuelle INV-253-14. - cancelExport : vérification ownership (export.userId === userId) sinon 403 FORBIDDEN_EXPORT_ACCESS. Si état terminal : 409 EXPORT_NOT_CANCELLABLE. Si ASSEMBLING et package déjà produit (champ ready_at non null) : retourner 409 avec état READY_FOR_DOWNLOAD inchangé (idempotence spec §5.5 flux 4). - confirmDownload : idempotent — si DOWNLOADED, retourne 200 sans re-audit. - getExport : vérification ownership ou 403 FORBIDDEN_EXPORT_ACCESS.

Pattern fail-closed audit :

// CORRECT — fail-closed (PD-85 learning)
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;
}

Interdit : - .catch(() => logger.error()) sur appel audit — anti-catch-absorb obligatoire - Publication BullMQ avant commitTransaction() - Math.random() pour tout identifiant

Fichiers : - src/modules/bulk-export/services/bulk-export.service.ts


CC-253-06 — Processor worker (assemblage asynchrone)

Module : bulk-export-processor Agent : agent-developer INV 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)

Interfaces : - BulkExportProcessor@Processor('pv-jobs-bulk-export') - BulkExportJobPayload : { exportId: BulkExportId }

Invariants : - purgeStale() au démarrage (INV-253-12, learning PD-283) : avant tout traitement, appel ExportCleanupService.purgeStaleTemp(exportId) pour éliminer tout résidu d'un run précédent crashé. - Timeout BullMQ = BULK_EXPORT_JOB_TIMEOUT_H * 3600 * 1000 ms. Si dépassé → FAILED_TIMEOUT. - Transition REQUESTED → ASSEMBLING en début de job avec timestamp assembling_started_at. - Sélection périmètre via ExportScopeService.resolveDocuments(export) — retourne la liste des document_id éligibles (actifs, soft-deleted inclus, détruits exclus). - Pour chaque document : ExportScopeService.buildDocumentEntry(documentId) récupère DocumentSecure + LegalCompositeProof (lecture seule). Si ProofEnvelope absente → FAILED avec PROOF_ENVELOPE_INCOMPLETE. Si blockchain_anchor.status = 'pending' → inclus tel quel (INV-253-08). - Construction BagIt via BagItAssemblerService. - Dual manifest via DualManifestService. - Signature HSM optionnelle via ExportSigningService (si probative_level = 'strong'). - Upload S3 staging via S3Service.putObject(key, stream). - Transition ASSEMBLING → READY_FOR_DOWNLOAD avec expires_at = now() + retention_ttl_h. - Audit à chaque transition : finally { await audit(transition) } pour garantir trace même en cas d'erreur partielle. - Fichiers temporaires locaux : répertoire /tmp/bulk-export-{exportId}/ créé en début, supprimé dans finally (ET purgeStale au démarrage).

Interdit : - Déchiffrement du contenu des documents (Zero-Knowledge — lecture metadata et ProofEnvelope uniquement) - Math.random() pour tout identifiant de corrélation - Fire-and-forget sur les transitions d'état - Accès à des documents d'un autre user_id

Fichiers : - src/modules/bulk-export/processors/bulk-export.processor.ts


CC-253-07 — Service sélection périmètre

Module : export-scope-service Agent : agent-developer INV couverts : INV-253-01 (exhaustivité GLOBAL), INV-253-09 (soft-deleted/destroyed)

Interfaces : - ExportScopeService : - resolveDocuments(export: BulkExport): Promise<DocumentExportEntry[]> - buildDocumentEntry(documentId, userId): Promise<DocumentExportEntry | null>

interface DocumentExportEntry {
  documentId: string;
  userId: UserId;
  isDeleted: boolean;         // soft-deleted (DocumentStatus.EXPIRED avec deleted_at non null)
  isDestroyed: boolean;       // marqué DESTROYED via PD-250
  documentSecure: DocumentSecure;
  proofEnvelope: LegalCompositeProof | null;
  destructionInfo?: DestructionLogEntry;
}

Invariants : - 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. - Documents détruits (PD-250) : exclus du payload principal, inclus dans destruction-log.json via DestructionLogService. - Comptage source == comptage export éligibles avant génération package. Mismatch → FAILED avec SCOPE_RESOLUTION_FAILED.

Interdit : - Inclusion de documents d'un autre user_id - Omission silencieuse de documents éligibles (INV-253-01) - Lecture du contenu S3 des documents (Zero-Knowledge)

Fichiers : - src/modules/bulk-export/services/export-scope.service.ts


CC-253-08 — Service assemblage BagIt

Module : bagit-assembler Agent : agent-developer INV couverts : INV-253-02 (ProofEnvelope complète), INV-253-03 (dual-hash), INV-253-04 (dual-manifest), INV-253-05 (intégrité)

Interfaces : - BagItAssemblerService : assemble(entries: DocumentExportEntry[], export: BulkExport): Promise<BagItPackage>

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)

Invariants : - metadata.json de chaque document contient : document_id, plaintext_hash (SHA3-256), ciphertext_hash (SHA3-256), ProofEnvelope complète, blockchain_anchor.status conservé tel quel (INV-253-08), is_deleted, deleted_at si applicable. - plaintext_hash et ciphertext_hash : regex ^[a-f0-9]{64}$ vérifiée fonctionnellement (validation fonctionnelle ≠ format — learning PD-283). Si invalide → export FAILED. - Manifests : <hex> <path> (2 espaces), chemin relatif depuis racine package, séparateur /, sans ... - Encodage UTF-8 sans BOM pour tous les JSON. - Chemins de fichiers dans les manifests : construits via path.posix.join() (pas path.join() qui utilise \ sur Windows).

Interdit : - Inclusion du fichier export.sig dans les manifests (il signe les manifests, pas l'inverse) - path.join() pour les chemins manifests (utiliser path.posix.join()) - Altération du contenu des content.enc (copie bit-à-bit depuis S3)

Fichiers : - src/modules/bulk-export/services/bagit-assembler.service.ts - src/modules/bulk-export/interfaces/bagit-package.interfaces.ts


CC-253-09 — Service dual-manifest

Module : dual-manifest Agent : agent-developer INV couverts : INV-253-04 (dual-manifest), INV-253-05 (intégrité package)

Interfaces : - DualManifestService : - generate(packageDir: string): Promise<{ sha256Manifest: string, sha3Manifest: string }> - verify(packageDir: string): Promise<{ valid: boolean, errors: string[] }>

Invariants : - Parcours récursif de data/ et metadata/ (pas manifest-*.txt ni export.sig) - 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é - Fichiers exclus des manifests : manifest-sha256.txt, manifest-sha3.txt, bagit.txt, bag-info.txt, export.sig

Interdit : - Inclusion de export.sig dans les manifests - SHA-256 seul (dual obligatoire) - Chemin absolu dans les manifests

Fichiers : - src/modules/bulk-export/services/dual-manifest.service.ts


CC-253-10 — Service destruction-log

Module : destruction-log Agent : agent-developer INV couverts : INV-253-09 (traçabilité destruction)

Interfaces : - DestructionLogService : - buildLog(exportId, destroyedEntries: DocumentExportEntry[]): Promise<DestructionLog>

Invariants : - Schéma DestructionLog conforme à la décision ECT-02 (§2 ci-dessus) - destruction_act_hash : lu depuis destruction_bordereau.hash (entité PD-250), regex ^[a-f0-9]{64}$ vérifiée fonctionnellement - bordereau_id : UUID v4, référence bordereaux PD-250 - Si destruction_act_hash invalide → export FAILED avec PROOF_ENVELOPE_INCOMPLETE - total_destroyed == entries.length (cohérence interne vérifiée) - Encodage UTF-8 sans BOM

Interdit : - Inclure des données personnelles directes (nom, prénom, email) dans les entrées - Omettre bordereau_id (INV-250-03 de PD-250 exige traçabilité bordereau)

Fichiers : - src/modules/bulk-export/services/destruction-log.service.ts - src/modules/bulk-export/interfaces/destruction-log.interfaces.ts


CC-253-11 — Service nettoyage et expiration

Module : export-cleanup Agent : agent-developer INV couverts : INV-253-12 (no-residuals)

Interfaces : - ExportCleanupService : - purgeStaleTemp(exportId: BulkExportId): Promise<void> — supprime /tmp/bulk-export-{exportId}/ - expireExport(exportId: BulkExportId): Promise<void> — transition → EXPIRED + purge S3

Scheduler (CRON) : - ExportExpiryScheduler : toutes les 15 min, sélectionne les bulk_exports avec status = 'READY_FOR_DOWNLOAD' ET expires_at <= NOW(), appelle expireExport() pour chacun.

Invariants : - purgeStaleTemp : appellé au démarrage de chaque job worker (learning PD-283, INV-253-12) - expireExport : supprime l'objet S3 via S3Service.deleteObject(packageS3Key) avant de transitionner en DB — fail-closed : si suppression S3 échoue, retry 3x puis log ERROR (pas de blocage de l'expiration DB) - Audit BULK_EXPORT_EXPIRED émis après transition - Aucune donnée temporaire résiduelle (vérifiable par TC-INV-12)

Interdit : - Expiration batch qui ne purge pas S3 (résidu de stockage) - purgeStaleTemp uniquement dans finally — DOIT être appelé en début de job aussi

Fichiers : - src/modules/bulk-export/services/export-cleanup.service.ts - src/modules/bulk-export/schedulers/export-expiry.scheduler.ts


CC-253-12 — Controller REST

Module : bulk-export-controller Agent : agent-developer INV couverts : INV-253-07 (quota), INV-253-10 (audit), INV-253-13 (accès inter-user)

Endpoints :

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

Invariants : - OidcJwtAuthGuard obligatoire sur tous les endpoints - Vérification ownership sur tous les endpoints avec :id (sinon 403 FORBIDDEN_EXPORT_ACCESS) - 409 EXPORT_ALREADY_ACTIVE si quota dépassé à la création - 409 EXPORT_NOT_CANCELLABLE si annulation sur état terminal - 410 DOWNLOAD_URL_EXPIRED si URL signée expirée (le service de présignature retourne une erreur détectable) - 410 EXPORT_EXPIRED si état EXPIRED sur GET download-url - Validation DTO via ValidationPipe (class-validator + class-transformer)

Interdit : - Endpoint sans OidcJwtAuthGuard - Exposer package_s3_key dans une réponse - Rate limiting global (doit être par userId)

Fichiers : - src/modules/bulk-export/controllers/bulk-export.controller.ts


5. Plan de tâches

Phase 0 — Infrastructure (parallélisable)

ID Tâche Dépendances Agent Parallélisable
T-253-01 Migration DDL vault_secure.bulk_exports agent-config Oui
T-253-02 Entité TypeORM BulkExport + enums + branded types T-253-01 agent-config Oui
T-253-03 Configuration BulkExportConfig + extension Joi schema agent-config Oui
T-253-04 Extension AuditActionType (8 nouveaux types) agent-config Oui

Phase 1 — Services fondamentaux (séquentiels)

ID Tâche Dépendances Agent Parallélisable
T-253-05 DTOs CreateBulkExportDto, BulkExportResponseDto, BulkExportDownloadUrlDto T-253-02 agent-developer Non
T-253-06 ExportStateMachineService (matrice transitions §5.4) T-253-02 agent-developer Oui (parallèle T-253-07)
T-253-07 ExportQuotaService (hasActiveExport) T-253-02 agent-developer Oui (parallèle T-253-06)
T-253-08 Exceptions métier BulkExportExceptions T-253-02 agent-developer Oui

Phase 2 — Services assemblage (parallélisables)

ID Tâche Dépendances Agent Parallélisable
T-253-09 ExportScopeService (4 granularités + résolution documents) T-253-02 agent-developer Oui
T-253-10 BagItAssemblerService (structure BagIt, metadata.json par doc) T-253-05 agent-developer Oui
T-253-11 DualManifestService (SHA-256 + SHA3-256, vérification) agent-developer Oui
T-253-12 DestructionLogService (schéma ECT-02) T-253-02 agent-developer Oui
T-253-13 ExportSigningService (signature HSM optionnelle export.sig) agent-developer Oui
T-253-14 ExportCleanupService + ExportExpiryScheduler T-253-06, T-253-04 agent-developer Oui
T-253-15 ExportDownloadService (URL signée + confirmDownload ECT-01) T-253-06 agent-developer Oui

Phase 3 — Orchestration (séquentiel)

ID Tâche Dépendances Agent Parallélisable
T-253-16 BulkExportService (création, annulation, confirmDownload) T-253-05 à T-253-08, T-253-04 agent-developer Non
T-253-17 BulkExportProcessor (worker BullMQ, assemblage complet) T-253-09 à T-253-15, T-253-16 agent-developer Non

Phase 4 — API et module (parallélisables)

ID Tâche Dépendances Agent Parallélisable
T-253-18 BulkExportController (5 endpoints) T-253-16, T-253-15 agent-developer Oui
T-253-19 BulkExportModule (déclaration NestJS, imports) T-253-18, T-253-17 agent-developer Non

Phase 5 — Tests (parallélisables)

ID Tâche Dépendances Agent Parallélisable
T-253-20 Tests unitaires services fondamentaux (StateMachine, Quota, Scope) T-253-06, T-253-07, T-253-09 agent-qa-unit Oui
T-253-21 Tests unitaires assemblage (BagIt, DualManifest, DestructionLog) T-253-10, T-253-11, T-253-12 agent-qa-unit Oui
T-253-22 Tests intégration flux complet (TC-NOM-01 à TC-NOM-14 + TC-NOM-15) T-253-17, T-253-19 agent-qa-integration Non
T-253-23 Tests sécurité (TC-SEC-01, TC-ERR-02/03, TC-NEG-05) T-253-18 agent-qa-security Oui
T-253-24 Tests invariants (TC-INV-11, TC-INV-12, TC-NOM-10 chaos) T-253-17 agent-qa-unit Oui

Graphe de dépendances (simplifié)

T-01, T-02, T-03, T-04 (parallèles)
T-05, T-06, T-07, T-08 (T-05 dépend T-02 ; T-06/07/08 parallèles)
T-09 à T-15 (parallèles entre eux, dépendent T-05 ou T-06)
T-16 (dépend T-05 à T-08 + T-04)
T-17 (dépend T-09 à T-15 + T-16)
T-18, T-19 (séquentiel)
T-20 à T-24 (parallèles entre eux)

6. Hypothèses explicites

ID Hypothèse Impact si fausse
H-253-01 Les modules amont (PD-282, PD-39, PD-37) fournissent des ProofEnvelopes contractuellement valides et complètes. Exports échouent en FAILED avec PROOF_ENVELOPE_INCOMPLETE. Acceptable : détection asynchrone contractualisée.
H-253-02 Le stockage staging S3/OVH supporte des objets jusqu'à 100 GB par export. Limite à revoir ; risque non-conformité volumétrique sur gros coffres.
H-253-03 Le mécanisme S3 de présignature supporte TTL configurable entre 1h et 72h. Vérifier la limite OVH Object Storage sur les URLs signées.
H-253-04 Les métriques P95 sont disponibles en environnement de référence instrumenté. CA-253-12 non vérifiable. Accepté : item de monitoring Sprint.
H-253-05 Le mapping des statuts de destruction légale (PD-250) est accessible via une requête DB depuis le module bulk-export. Risque d'inclusion/exclusion incorrecte. Requête directe sur destruction_bordereaux via schema vault_secure.
H-253-06 La signature HSM peut être indisponible sans bloquer la réversibilité. Seul niveau standard disponible. Acceptable : INV-253-06.
H-253-07 La rétention uniforme post-téléchargement (indépendante du statut DOWNLOADED) est acceptable pour PD-253. Une purge anticipée sur confirmation pourra être ajoutée dans une story ultérieure.
H-253-08 La sélection du périmètre est effectuée au démarrage du worker (moment ASSEMBLING), pas au moment REQUESTED. Un document créé dans la fenêtre REQUESTED → ASSEMBLING n'est pas inclus. Acceptable : fenêtre de quelques secondes.
H-253-09 La copie content.enc depuis S3 vers le répertoire temp est incluse dans le package BagIt (export contient le binaire chiffré). Alternative : package contient uniquement les métadonnées et preuves (sans le binaire). Décision retenue : inclusion du binaire chiffré pour réversibilité NF Z42-013 §13.1 (auto-porteur).
H-253-10 Les fichiers temporaires /tmp/bulk-export-{exportId}/ ne contiennent aucun secret cryptographique (PD-253 est Zero-Knowledge côté serveur : les données manipulées sont content.enc déjà chiffré, et des hashes non-réversibles). Le chiffrement au repos pour INV-253-11 est fourni par le chiffrement disque hôte (disk encryption du serveur). Si le serveur hôte n'a pas de chiffrement disque, INV-253-11 est dégradé. À documenter dans le runbook opérationnel.
H-253-11 L'upload du package BagIt vers S3/OVH staging utilise le multipart upload (@aws-sdk/lib-storage Upload) pour les packages > 5GB. S3Service.uploadFile() est un stub (TODO) et devra être étendu par CC-253-07 (BagItAssemblerService) pour déléguer l'upload multipart. La limite PutObjectCommand = 5GB (S3 spec) ; packages jusqu'à 100GB requièrent obligatoirement multipart. Si S3Service n'est pas étendu, les packages > 5GB échouent avec EntityTooLarge. Criticité : MAJEUR (bloque les exports de gros coffres).

7. Contraintes techniques

7.1 Stack réelle

  • NestJS : version existante du projet (^10.x)
  • TypeORM : version existante (^0.3.x)
  • PostgreSQL : schéma vault_secure (RLS activé)
  • BullMQ v5 : getJobSchedulers() / removeJobScheduler() — PAS getRepeatableJobs() / removeRepeatableByKey() (learning PD-55)
  • js-sha3 : dépendance existante pour SHA3-256 et SHA3-384
  • S3/OVH Object Storage : S3Service existant (src/modules/storage/s3.service.ts)
  • HSM CloudHSM : via HsmService existant (PD-37) — mode optionnel

7.2 Migrations DDL

-- Migration: 1742000000000-PD253-CreateBulkExports.ts
-- Pattern idempotent CREATE TABLE IF NOT EXISTS
-- Aucun ALTER TYPE ADD VALUE (nouvel enum VARCHAR + CHECK, pas de type PG)
-- Aucun index partiel avec subquery (learning PD-55)

La stratégie VARCHAR + CHECK pour les enums évite le piège ALTER TYPE ADD VALUE / commitTransaction() (learning PD-282, PD-279).

7.3 BullMQ — Nom de queue

Nom de queue : pv-jobs-bulk-export (sans ':', learning PD-250 INV-250-13). Timeout configuré via settings.stalledInterval et lockDuration BullMQ.

7.4 Fichiers temporaires

Répertoire temporaire : /tmp/bulk-export-{exportId}/ Création : fs.mkdir() au démarrage du processor. Nettoyage : finally { await cleanup.purgeStaleTemp(exportId) } + purgeStaleTemp() au démarrage (double nettoyage — learning PD-283).

7.5 crypto.randomUUID()

Tous les identifiants générés (corrélation, trace) utilisent import { randomUUID } from 'node:crypto' — pas Math.random() (learning PD-63, Sonar S2245).

7.6 Anti-catch-absorb (learning PD-85)

Chaque appel auditService.log() dans le module bulk-export est en dehors de tout catch absorb. Pattern finally { await audit() } ou re-throw obligatoire.

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

  • plaintext_hash et ciphertext_hash : validation format (regex ^[a-f0-9]{64}$) ET vérification que le hash est utilisé (référencé dans le manifeste Merkle pour ciphertext_hash).
  • destruction_act_hash : validation format ET vérification que le hash correspond à un bordereau existant en DB.
  • package_s3_key : validation format ET vérification existence de l'objet S3 avant génération URL signée.

7bis. Diagrammes Mermaid

7bis.1 Graphe de dépendances des modules (ordre d'exécution agents)

graph LR
    CC01["CC-253-01<br/>Entity, Migration, Enums<br/>(agent-config)"]
    CC02["CC-253-02<br/>Configuration<br/>(agent-config)"]
    CC03["CC-253-03<br/>DTOs<br/>(agent-developer)"]
    CC04["CC-253-04<br/>Quota + StateMachine<br/>(agent-developer)"]
    CC05["CC-253-05<br/>BulkExportService<br/>(agent-developer)"]
    CC06["CC-253-06<br/>BulkExportProcessor<br/>(agent-developer)"]
    CC07["CC-253-07<br/>ExportScopeService<br/>(agent-developer)"]
    CC08["CC-253-08<br/>BagItAssemblerService<br/>(agent-developer)"]
    CC09["CC-253-09<br/>DualManifestService<br/>(agent-developer)"]
    CC10["CC-253-10<br/>DestructionLogService<br/>(agent-developer)"]
    CC11["CC-253-11<br/>ExportCleanupService<br/>+ Scheduler<br/>(agent-developer)"]
    CC12["CC-253-12<br/>BulkExportController<br/>(agent-developer)"]

    CC01 --> CC03
    CC01 --> CC04
    CC01 --> CC07
    CC01 --> CC10
    CC02 --> CC05
    CC03 --> CC05
    CC03 --> CC08
    CC04 --> CC05
    CC04 --> CC11
    CC05 --> CC06
    CC05 --> CC12
    CC07 --> CC06
    CC08 --> CC06
    CC09 --> CC06
    CC09 --> CC08
    CC10 --> CC06
    CC11 --> CC06
    CC06 --> CC12

Lecture : Les fleches indiquent "est requis par". Les modules sans dependance entrante (CC-253-01, CC-253-02, CC-253-09) peuvent etre executes en premier. Le chemin critique est CC-253-01 -> CC-253-03/CC-253-04 -> CC-253-05 -> CC-253-06 -> CC-253-12.

7bis.2 Sequence — Flux de creation et assemblage d'un export bulk

sequenceDiagram
    participant Client
    participant Controller as BulkExportController
    participant Service as BulkExportService
    participant Quota as ExportQuotaService
    participant Audit as AuditService
    participant DB as PostgreSQL
    participant Queue as BullMQ
    participant Processor as BulkExportProcessor
    participant Cleanup as ExportCleanupService
    participant Scope as ExportScopeService
    participant BagIt as BagItAssemblerService
    participant Manifest as DualManifestService
    participant Destruction as DestructionLogService
    participant Signing as ExportSigningService
    participant S3 as S3Service

    Client->>Controller: POST /bulk-exports (scope, filters)
    Controller->>Service: createExport(userId, dto)
    Service->>DB: BEGIN TRANSACTION
    Service->>Quota: hasActiveExport(userId)
    Quota->>DB: COUNT(*) WHERE status IN (REQUESTED, ASSEMBLING, READY_FOR_DOWNLOAD)
    DB-->>Quota: count
    alt count > 0
        Service-->>Controller: 409 EXPORT_ALREADY_ACTIVE
    end
    Service->>Audit: log(BULK_EXPORT_CREATED)
    Note over Audit: fail-closed: si echec -> rollback
    Service->>DB: INSERT bulk_exports (REQUESTED)
    Service->>DB: COMMIT
    Service->>Queue: add('assemble', exportId)
    Controller-->>Client: 202 Accepted (BulkExportResponseDto)

    Note over Queue,Processor: Traitement asynchrone (worker)

    Queue->>Processor: job(exportId)
    Processor->>Cleanup: purgeStaleTemp(exportId)
    Processor->>DB: UPDATE status = ASSEMBLING
    Processor->>Audit: log(BULK_EXPORT_ASSEMBLING)
    Processor->>Scope: resolveDocuments(export)
    Scope->>DB: SELECT DocumentSecure WHERE user_id + scope filters
    DB-->>Scope: documents[]
    Scope-->>Processor: DocumentExportEntry[]

    loop Pour chaque document
        Processor->>S3: getObject(content.enc)
        S3-->>Processor: encrypted binary stream
    end

    Processor->>BagIt: assemble(entries, export)
    BagIt->>Manifest: generate(packageDir)
    Manifest-->>BagIt: sha256Manifest + sha3Manifest
    BagIt->>Destruction: buildLog(exportId, destroyedEntries)
    Destruction-->>BagIt: destruction-log.json
    BagIt-->>Processor: BagItPackage

    opt probative_level = strong
        Processor->>Signing: signPackage(manifests)
        Signing-->>Processor: export.sig
    end

    Processor->>S3: putObject(packageS3Key, package)
    Processor->>DB: UPDATE status = READY_FOR_DOWNLOAD, expires_at
    Processor->>Audit: log(BULK_EXPORT_READY)

    Note over Client,Controller: Telechargement ulterieur

    Client->>Controller: GET /bulk-exports/:id/download-url
    Controller->>S3: generatePresignedUrl(packageS3Key, TTL)
    S3-->>Controller: presigned URL
    Controller-->>Client: 200 (url, expiresAt)

    Client->>Controller: POST /bulk-exports/:id/confirm-download
    Controller->>Service: confirmDownload(userId, exportId)
    Service->>DB: UPDATE status = DOWNLOADED
    Service->>Audit: log(BULK_EXPORT_DOWNLOADED)
    Controller-->>Client: 200 OK

8. Points de contrôle Gate 5 — Mapping INV/CA → Tâches

INV / CA Description Tâche(s) responsable(s)
INV-253-01 Exhaustivité GLOBAL — pas de succès partiel silencieux T-253-09 (scope), T-253-17 (processor — comptage source vs export)
INV-253-02 ProofEnvelope complète (exception pending anchor) T-253-10 (bagit assembler — vérification chaque doc), T-253-24 (TC-NOM-02, TC-ERR-06)
INV-253-03 Dual-hash + chaîne Merkle sur ciphertext_hash T-253-10 (metadata.json), T-253-21 (TC-NOM-03)
INV-253-04 Dual manifest obligatoire T-253-11 (DualManifestService), T-253-21 (TC-NOM-04)
INV-253-05 Intégrité package — altération détectable T-253-11 (DualManifestService.verify), T-253-21 (TC-ERR-12)
INV-253-06 Signature optionnelle — standard valide sans export.sig T-253-13 (ExportSigningService optionnel), T-253-21 (TC-NOM-05/06)
INV-253-07 Quota 1 actif / user T-253-07 (ExportQuotaService), T-253-16 (création), T-253-22 (TC-ERR-04)
INV-253-08 Pending anchor conservé tel quel T-253-10 (bagit assembler — copie status brut), T-253-22 (TC-NOM-07)
INV-253-09 Soft-deleted inclus / détruits exclus + tracés T-253-09 (scope), T-253-12 (destruction-log), T-253-22 (TC-NOM-08)
INV-253-10 Audit fail-closed T-253-04 (types audit), T-253-16 (service création — pattern fail-closed), T-253-22 (TC-ERR-07)
INV-253-11 Chiffrement artefacts temporaires T-253-17 (processor — pas de secret en clair sur disque), T-253-24 (TC-INV-11)
INV-253-12 No-residuals (purgeStale + finally) T-253-14 (ExportCleanupService), T-253-17 (processor), T-253-24 (TC-INV-12)
INV-253-13 Machine à états — transitions explicites T-253-06 (StateMachine), T-253-20 (TC-NOM-09, TC-NOM-13/14)
INV-253-14 Atomicité sync/async (DB ACID + BullMQ post-commit) T-253-16 (BulkExportService — pattern queryRunner), T-253-24 (TC-NOM-10)
CA-253-08 Audit échoue → export non créé T-253-16 (fail-closed), T-253-22 (TC-ERR-07)
CA-253-09 Expiration TTL → EXPIRED + purge T-253-14 (scheduler), T-253-22 (TC-NOM-11)
CA-253-10 États terminaux bloqués T-253-06 (StateMachine.isTerminal), T-253-20 (TC-NOM-09)
CA-253-12 Dégradation P95 ≤ 20% T-253-17 (worker async isolé), T-253-22 (TC-NOM-12 si env instrumenté)
ECT-01 Mécanisme confirmation téléchargement T-253-15 (ExportDownloadService.confirmDownload), T-253-18 (endpoint POST confirm-download), T-253-22 (TC-NOM-15)
ECT-02 Schéma destruction-log.json complet T-253-12 (DestructionLogService), T-253-21 (TC-NOM-08)
ECT-03 failure_reason enum contractualisé T-253-01 (migration CHECK constraint), T-253-05 (DTO), T-253-22 (TC-ERR-06)
ECT-05 Politique purge post-téléchargement H-253-07 (rétention uniforme), T-253-14 (scheduler uniforme)
ECT-06 Libération quota après terminal T-253-07 (ExportQuotaService — COUNT filtre terminaux), T-253-20 (test)

9. Code-contracts récapitulatif

ID Module INV couverts Agent
CC-253-01 Entité, migration, enums INV-253-13, INV-253-14 agent-config
CC-253-02 Configuration INV-253-07, SLA agent-config
CC-253-03 DTOs INV-253-07, INV-253-13 agent-developer
CC-253-04 Quota + StateMachine INV-253-07, INV-253-13, INV-253-14 agent-developer
CC-253-05 BulkExportService INV-253-10, INV-253-14 agent-developer
CC-253-06 BulkExportProcessor INV-253-01, INV-253-02, INV-253-08, INV-253-09, INV-253-11, INV-253-12 agent-developer
CC-253-07 ExportScopeService INV-253-01, INV-253-09 agent-developer
CC-253-08 BagItAssemblerService INV-253-02, INV-253-03, INV-253-04, INV-253-05 agent-developer
CC-253-09 DualManifestService INV-253-04, INV-253-05 agent-developer
CC-253-10 DestructionLogService INV-253-09 agent-developer
CC-253-11 ExportCleanupService + Scheduler INV-253-12 agent-developer
CC-253-12 BulkExportController INV-253-07, INV-253-10, INV-253-13 agent-developer

10. Stubs inter-PD à documenter

Conformément au learning REX PD-250 (stubs inter-PD avec story destination obligatoire) :

Stub Localisation Story destination Criticité si absent
HsmService.sign() pour export.sig ExportSigningService PD-37 (existant, consommé) mineur (fonctionnalité optionnelle)
TsaService.timestamp() si TSA sur export.sig ExportSigningService PD-39 (existant, consommé) mineur
Notification disponibilité (email/push) BulkExportService post-READY PC-253-03 (future story) // STUB: PC-253-03 — notification canal externe

Références

  • Spec : PD-253-specification.md (v2)
  • Tests : PD-253-tests.md (v2)
  • Gate 3 : PD-253-verdict-step3-v2.yaml (RESERVE — ECT-01/02/03 adressés dans ce plan)
  • Dépendances : PD-85 (ExportEngine), PD-282 (ProofEnvelope), PD-39 (TSA), PD-37 (HSM), PD-250 (destruction)
  • Standards : NF Z42-013:2020 §13.1, ISO 14641, RGPD Art.20, BagIt RFC 8493