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()— PASgetRepeatableJobs()/removeRepeatableByKey()(learning PD-55) - js-sha3 : dépendance existante pour SHA3-256 et SHA3-384
- S3/OVH Object Storage :
S3Serviceexistant (src/modules/storage/s3.service.ts) - HSM CloudHSM : via
HsmServiceexistant (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_hashetciphertext_hash: validation format (regex^[a-f0-9]{64}$) ET vérification que le hash est utilisé (référencé dans le manifeste Merkle pourciphertext_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