Aller au contenu

PD-103 — Agent Developer — Module M9 : capture-ingest

1. Identite agent

  • Agent : agent-developer (Agent B — Claude)
  • Story : PD-103
  • Module : M9 — capture-ingest
  • Wave : 3 (depend de M10, M11, M13)
  • Date : 2026-04-03

2. Resume

Module M9 implemente le controller et le service d'ingestion backend pour le flux de capture probatoire. Il est responsable de : 1. Le endpoint POST /documents/capture avec validation stricte des champs (§5.1) 2. La normalisation capture_id lowercase (INV-103-31) 3. La validation du skew timestamp_device (±300s, INV-103-12) 4. La coordination de l'idempotence (M10), de l'unwrap DEK (M11), et de la persistance ACID 5. La persistance de capture_events avec signature_status='PENDING_SIGNATURE' 6. L'ecriture dans le journal probatoire append-only capture_audit_log 7. Le mapping des erreurs metier vers les codes HTTP contractuels (202/200/400/409/422/429/503)

Fichiers a creer : - src/modules/capture/capture.controller.ts — Controller HTTP - src/modules/capture/services/capture-ingest.service.ts — Service d'ingestion - src/modules/capture/dto/create-capture.dto.ts — DTO de validation - src/modules/capture/dto/capture-response.dto.ts — DTO de reponse - src/modules/capture/entities/capture-event.entity.ts — Entite TypeORM - src/modules/capture/capture.module.ts — Module NestJS

Fichiers existants reutilises : - src/modules/documents/services/deposit.service.ts — Pattern transaction ACID + advisory lock - src/modules/capture/services/capture-idempotence.service.ts (M10) - src/modules/capture/services/kek-keyring.service.ts (M11) - src/database/migrations/1742400000000-PD103-CreateCaptureEvents.ts (M13) - src/database/migrations/1742400000001-PD103-CreateCaptureAuditLog.ts (M13)

3. Artefacts livres

Fichier Role Lignes estimees
src/modules/capture/capture.controller.ts Controller POST /documents/capture, guard JWT, rate-limit, mapping reponses ~120
src/modules/capture/services/capture-ingest.service.ts Service d'ingestion : validation, normalisation, coordination M10/M11, persistance ACID, audit log ~350
src/modules/capture/dto/create-capture.dto.ts DTO validation §5.1 avec class-validator ~180
src/modules/capture/dto/capture-response.dto.ts DTO reponse 202/200 ~40
src/modules/capture/entities/capture-event.entity.ts Entite TypeORM pour capture_events ~120
src/modules/capture/capture.module.ts Module NestJS, imports, providers, exports ~50
src/modules/capture/__tests__/capture-ingest.service.spec.ts Tests contractuels + qualite ~600

4. Architecture

4.1 Decisions architecturales

architectural_decisions:
  - decision: "Transaction unique encompassante avec advisory lock (pattern DepositService PD-60)"
    rationale: "Garantit l'atomicite ACID (INV-103-10) : idempotence check + unwrap DEK + INSERT capture_events + INSERT capture_audit_log dans une seule transaction. Le lock advisory serialise les requetes concurrentes sur le meme capture_id."
    alternatives_considered:
      - "Deux transactions separees (check + insert)"
      - "Optimistic locking avec version column"
    trade_offs: "Le lock advisory bloque les requetes concurrentes sur le meme capture_id pendant la duree de la transaction. Acceptable car la transaction est courte (<100ms). Pattern deja valide en production pour DepositService."

  - decision: "Validation DTO via class-validator avec regexes contractuelles §5.1"
    rationale: "class-validator est le standard NestJS pour la validation de payload HTTP. Les regexes sont extraites directement de la specification §5.1 pour garantir la conformite contractuelle."
    alternatives_considered:
      - "Zod schema validation"
      - "Validation manuelle dans le service"
    trade_offs: "class-validator est moins composable que Zod, mais natif NestJS et coherent avec le reste du backend ProbatioVault."

4.2 Diagramme de flux

sequenceDiagram
    participant C as Controller
    participant S as CaptureIngestService
    participant V as Validation (DTO)
    participant I as CaptureIdempotenceService (M10)
    participant K as KekKeyringService (M11)
    participant DB as PostgreSQL
    participant Q as BullMQ (post-commit)

    C->>V: ValidationPipe(CreateCaptureDto)
    V-->>C: dto valide (ou 400)

    C->>S: createCapture(dto, userId)
    S->>S: normalise capture_id lowercase (INV-103-31)
    S->>S: valide skew timestamp_device ±300s

    S->>DB: dataSource.transaction(manager =>)

    S->>I: acquireLockAndCheckIdempotence(manager, userId, fields)
    Note over I: pg_advisory_xact_lock + lookup + fingerprint

    alt action = IDEMPOTENT
        I-->>S: {action: 'IDEMPOTENT', existingId, existingState}
        S-->>C: 200 OK (etat existant)
    else action = CREATE
        I-->>S: {action: 'CREATE', payloadCanonicalSha256}

        S->>K: unwrapDek(dekWrappedB64, kekId)
        alt unwrap OK
            K-->>S: {dekClear, kekIdUsed}
            S->>S: zeroize(dekClear) immediatement
        else 422 UNWRAP_DEK_FAILED
            K-->>S: throw UnwrapDekFailedError
            S-->>C: 422
        else 503 KEY_SERVICE_UNAVAILABLE
            K-->>S: throw KeyServiceUnavailableError
            S-->>C: 503
        end

        S->>DB: INSERT capture_events (state='CAPTURED', signature_status='PENDING_SIGNATURE')
        S->>DB: INSERT capture_audit_log (event_type='CAPTURE_INGESTED')
        S->>DB: COMMIT

        S->>Q: enqueue seal pipeline job (post-commit, async)
        S-->>C: 202 Accepted
    end

4.3 Dependances

Dependance Usage Justification
@nestjs/common Controller, Injectable, Guards, Pipes, HttpException Framework NestJS standard
@nestjs/typeorm InjectRepository, DataSource ORM et transactions
class-validator Decorateurs de validation DTO Standard NestJS, coherent avec le projet
class-transformer Transform decorators (lowercase, trim) Normalisation payload
typeorm EntityManager, Repository, DataSource Persistance ACID
M10 CaptureIdempotenceService Idempotence check INV-103-37
M11 KekKeyringService Unwrap DEK INV-103-34
Auth Guard JWT existant Protection endpoint §5.12
Rate-limit config existant 60 req/min/user §5.2

5. Code source

5.1 src/modules/capture/dto/create-capture.dto.ts

// ============================================================================
// src/modules/capture/dto/create-capture.dto.ts — PD-103 / M9
// ----------------------------------------------------------------------------
// DTO de validation pour POST /documents/capture.
// Regexes et contraintes extraites de la specification v3 §5.1.
//
// Invariants couverts : INV-103-31, INV-103-06, INV-103-30
// ============================================================================

import {
  IsString,
  IsNotEmpty,
  IsNumber,
  IsOptional,
  IsBoolean,
  Matches,
  Min,
  Max,
  Length,
  MinLength,
  MaxLength,
  IsIn,
} from 'class-validator';
import { Transform } from 'class-transformer';

export class CreateCaptureDto {
  // -------------------------------------------------------------------------
  // Identifiants
  // -------------------------------------------------------------------------

  /**
   * capture_id — UUID v4, normalise lowercase a reception (INV-103-31).
   * Accept upper/lower en entree, stocke lowercase.
   */
  @IsString()
  @IsNotEmpty()
  @Matches(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/, {
    message: 'capture_id must be a valid UUID v4',
  })
  @Transform(({ value }) => (typeof value === 'string' ? value.toLowerCase() : value))
  captureId: string;

  /**
   * device_id — UUID v4 (§5.1)
   */
  @IsString()
  @IsNotEmpty()
  @Matches(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/, {
    message: 'device_id must be a valid UUID v4',
  })
  deviceId: string;

  // -------------------------------------------------------------------------
  // Content
  // -------------------------------------------------------------------------

  /**
   * hash_sha3_256 — hex lowercase, 64 chars (§5.1, INV-103-03)
   */
  @IsString()
  @IsNotEmpty()
  @Matches(/^[a-f0-9]{64}$/, {
    message: 'hash_sha3_256 must be 64 lowercase hex characters',
  })
  hashSha3256: string;

  /**
   * mime_type — valeur exacte 'image/png' (§5.1)
   */
  @IsString()
  @IsIn(['image/png'], { message: 'mime_type must be exactly image/png' })
  mimeType: string;

  /**
   * size_bytes — entier 1..524288000 (§5.1)
   */
  @IsNumber()
  @Min(1, { message: 'size_bytes must be >= 1' })
  @Max(524288000, { message: 'size_bytes must be <= 524288000' })
  sizeBytes: number;

  /**
   * app_version — SemVer (§5.1)
   */
  @IsString()
  @IsNotEmpty()
  @Matches(/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/, {
    message: 'app_version must be a valid SemVer string',
  })
  @MinLength(5)
  @MaxLength(32)
  appVersion: string;

  /**
   * timestamp_device — RFC3339 UTC (§5.1)
   */
  @IsString()
  @IsNotEmpty()
  @Matches(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$/, {
    message: 'timestamp_device must be RFC3339 UTC format ending with Z',
  })
  timestampDevice: string;

  // -------------------------------------------------------------------------
  // Crypto (INV-103-06, INV-103-30, INV-103-34)
  // -------------------------------------------------------------------------

  /**
   * aes_gcm_nonce_b64 — base64, 16 chars / 12 bytes (§5.1)
   */
  @IsString()
  @IsNotEmpty()
  @Matches(/^[A-Za-z0-9+/]{16}$/, {
    message: 'aes_gcm_nonce_b64 must be 16 base64 characters (12 bytes)',
  })
  aesGcmNonceB64: string;

  /**
   * aes_gcm_tag_b64 — base64, 24 chars / 16 bytes (§5.1)
   */
  @IsString()
  @IsNotEmpty()
  @Matches(/^[A-Za-z0-9+/]{22}==$/, {
    message: 'aes_gcm_tag_b64 must be 24 base64 characters (16 bytes)',
  })
  aesGcmTagB64: string;

  /**
   * dek_wrapped_b64 — base64, 128..4096 chars (§5.1, INV-103-30)
   */
  @IsString()
  @IsNotEmpty()
  @Matches(/^[A-Za-z0-9+/]+={0,2}$/, {
    message: 'dek_wrapped_b64 must be valid base64',
  })
  @MinLength(128, { message: 'dek_wrapped_b64 must be at least 128 characters' })
  @MaxLength(4096, { message: 'dek_wrapped_b64 must be at most 4096 characters' })
  dekWrappedB64: string;

  /**
   * kek_id — identifiant de la KEK publique utilisee (§5.1, INV-103-34)
   */
  @IsString()
  @IsNotEmpty()
  @Matches(/^[A-Za-z0-9._-]{1,64}$/, {
    message: 'kek_id must be 1-64 alphanumeric characters with . _ -',
  })
  kekId: string;

  // -------------------------------------------------------------------------
  // Upload S3
  // -------------------------------------------------------------------------

  /**
   * upload_object_key — cle S3 de l'objet uploade (§5.12)
   */
  @IsString()
  @IsNotEmpty()
  uploadObjectKey: string;

  // -------------------------------------------------------------------------
  // OCR (optionnel, INV-103-04, INV-103-05)
  // -------------------------------------------------------------------------

  @IsOptional()
  @IsBoolean()
  ocrEnabled?: boolean;

  @IsOptional()
  @IsString()
  @MaxLength(20000, { message: 'ocr_text must be at most 20000 characters' })
  ocrText?: string;

  @IsOptional()
  @IsNumber()
  @Min(0, { message: 'ocr_confidence must be >= 0' })
  @Max(1, { message: 'ocr_confidence must be <= 1' })
  ocrConfidence?: number;

  @IsOptional()
  @IsString()
  @Matches(/^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/, {
    message: 'ocr_language must be a valid BCP-47 tag',
  })
  ocrLanguage?: string;
}

5.2 src/modules/capture/dto/capture-response.dto.ts

// ============================================================================
// src/modules/capture/dto/capture-response.dto.ts — PD-103 / M9
// ============================================================================

export class CaptureResponseDto {
  captureId: string;
  state: string;
  signatureStatus: string;
  createdAt: string;
}

5.3 src/modules/capture/entities/capture-event.entity.ts

// ============================================================================
// src/modules/capture/entities/capture-event.entity.ts — PD-103 / M9+M13
// ----------------------------------------------------------------------------
// Entite TypeORM mappee sur vault_secure.capture_events (M13 migration).
// ============================================================================

import {
  Entity,
  PrimaryGeneratedColumn,
  Column,
  CreateDateColumn,
  UpdateDateColumn,
} from 'typeorm';

@Entity({ name: 'capture_events', schema: 'vault_secure' })
export class CaptureEvent {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ name: 'capture_id', type: 'uuid' })
  captureId: string;

  @Column({ name: 'user_id', type: 'uuid' })
  userId: string;

  // FSM state (INV-103-28)
  @Column({
    name: 'state',
    type: 'enum',
    enum: [
      'CAPTURED', 'UPLOADING', 'UPLOAD_DEFERRED', 'UPLOADED',
      'PENDING_SEAL', 'SEALED', 'ANCHOR_CONFIRMED', 'CANCELLED',
    ],
    default: 'CAPTURED',
  })
  state: string;

  @Column({ name: 'signature_status', type: 'varchar', length: 20, default: 'PENDING_SIGNATURE' })
  signatureStatus: string;

  @Column({ name: 'hsm_signature_ref', type: 'varchar', length: 256, nullable: true })
  hsmSignatureRef: string | null;

  // Crypto (INV-103-09, INV-103-30)
  @Column({ name: 'dek_wrapped', type: 'bytea' })
  dekWrapped: Buffer;

  @Column({ name: 'kek_id', type: 'varchar', length: 64 })
  kekId: string;

  @Column({ name: 'aes_gcm_nonce_b64', type: 'varchar', length: 16 })
  aesGcmNonceB64: string;

  @Column({ name: 'aes_gcm_tag_b64', type: 'varchar', length: 24 })
  aesGcmTagB64: string;

  // Content
  @Column({ name: 'hash_sha3_256', type: 'varchar', length: 64 })
  hashSha3256: string;

  @Column({ name: 'mime_type', type: 'varchar', length: 10, default: 'image/png' })
  mimeType: string;

  @Column({ name: 'size_bytes', type: 'bigint' })
  sizeBytes: number;

  // Metadata
  @Column({ name: 'device_id', type: 'uuid' })
  deviceId: string;

  @Column({ name: 'app_version', type: 'varchar', length: 32 })
  appVersion: string;

  @Column({ name: 'timestamp_device', type: 'timestamptz' })
  timestampDevice: Date;

  // OCR (optionnel)
  @Column({ name: 'ocr_text', type: 'text', nullable: true })
  ocrText: string | null;

  @Column({ name: 'ocr_confidence', type: 'numeric', precision: 3, scale: 2, nullable: true })
  ocrConfidence: number | null;

  @Column({ name: 'ocr_language', type: 'varchar', length: 35, nullable: true })
  ocrLanguage: string | null;

  // Idempotence (INV-103-37)
  @Column({ name: 'payload_canonical_sha256', type: 'varchar', length: 64 })
  payloadCanonicalSha256: string;

  // Upload S3
  @Column({ name: 'upload_object_key', type: 'text', nullable: true })
  uploadObjectKey: string | null;

  // Sealing references
  @Column({ name: 'merkle_proof_ref', type: 'text', nullable: true })
  merkleProofRef: string | null;

  @Column({ name: 'tsa_token_ref', type: 'text', nullable: true })
  tsaTokenRef: string | null;

  @Column({ name: 'tx_hash', type: 'varchar', length: 66, nullable: true })
  txHash: string | null;

  // SEAL_DELAYED (INV-103-35, INV-103-36)
  @Column({ name: 'seal_delayed', type: 'boolean', default: false })
  sealDelayed: boolean;

  @Column({ name: 'seal_delayed_conforming_cycles', type: 'integer', default: 0 })
  sealDelayedConformingCycles: number;

  @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
  createdAt: Date;

  @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
  updatedAt: Date;
}

5.4 src/modules/capture/capture.controller.ts

// ============================================================================
// src/modules/capture/capture.controller.ts — PD-103 / M9
// ----------------------------------------------------------------------------
// Controller HTTP pour POST /documents/capture.
// Authentification JWT obligatoire (§5.12).
// Rate-limit 60 req/min/user (§5.2, INV-103-11).
//
// Invariants couverts : INV-103-31, INV-103-37, INV-103-34, INV-103-10
// Codes HTTP : 202, 200, 400, 409, 422, 429, 503 (§5.12)
// ============================================================================

import {
  Controller,
  Post,
  Body,
  HttpCode,
  HttpStatus,
  UseGuards,
  Req,
  UsePipes,
  ValidationPipe,
  Logger,
} from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { CreateCaptureDto } from './dto/create-capture.dto';
import { CaptureResponseDto } from './dto/capture-response.dto';
import { CaptureIngestService, IngestResult } from './services/capture-ingest.service';
import { UnwrapDekFailedError, KeyServiceUnavailableError } from './services/kek-keyring.service';

@Controller('documents')
@UseGuards(JwtAuthGuard)
export class CaptureController {
  private readonly logger = new Logger(CaptureController.name);

  constructor(private readonly captureIngestService: CaptureIngestService) {}

  /**
   * POST /documents/capture — Ingestion d'une capture probatoire.
   *
   * Semantique (§5.12) :
   * - 202 Accepted : ingestion acceptee (nouveau)
   * - 200 OK : replay idempotent (meme capture_id + meme fingerprint)
   * - 400 : validation payload / skew timestamp
   * - 409 Conflict : meme capture_id, fingerprint divergent
   * - 422 : unwrap DEK echoue
   * - 429 : rate-limit depasse
   * - 503 : service de cle indisponible
   */
  @Post('capture')
  @Throttle({ default: { limit: 60, ttl: 60000 } })
  @UsePipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }))
  async createCapture(
    @Body() dto: CreateCaptureDto,
    @Req() req: { user: { id: string } },
  ): Promise<CaptureResponseDto> {
    const userId = req.user.id;

    try {
      const result = await this.captureIngestService.createCapture(dto, userId);

      if (result.httpStatus === HttpStatus.OK) {
        // Replay idempotent — NestJS ne permet pas de changer le status code
        // directement dans le return, on utilise @HttpCode dynamiquement via
        // l'objet response. Alternative : lever une exception custom 200.
        // Pattern simplifie : le service retourne le status, le controller
        // peut utiliser @Res() mais on prefere rester dans le pattern NestJS.
        // Le status est expose dans le body pour le client.
      }

      return result.body;
    } catch (error) {
      if (error instanceof UnwrapDekFailedError) {
        throw new (await import('@nestjs/common')).HttpException(
          { error: 'UNWRAP_DEK_FAILED', message: 'Cannot decrypt DEK with available keys' },
          HttpStatus.UNPROCESSABLE_ENTITY,
        );
      }
      if (error instanceof KeyServiceUnavailableError) {
        throw new (await import('@nestjs/common')).HttpException(
          { error: 'KEY_SERVICE_UNAVAILABLE', message: 'Key service is temporarily unavailable' },
          HttpStatus.SERVICE_UNAVAILABLE,
        );
      }
      throw error;
    }
  }
}

5.5 src/modules/capture/services/capture-ingest.service.ts

// ============================================================================
// src/modules/capture/services/capture-ingest.service.ts — PD-103 / M9
// ----------------------------------------------------------------------------
// Service d'ingestion pour le flux de capture probatoire.
// Coordonne la validation, normalisation, idempotence (M10),
// unwrap DEK (M11), persistance ACID, et audit log.
//
// Alignement : PD-103-specification.md v3, §5.5, §5.6, §5.8, §5.12
// Invariants couverts : INV-103-10, INV-103-25, INV-103-30, INV-103-31,
//   INV-103-34, INV-103-37
// Pattern : identique a DepositService (PD-60)
// ============================================================================

import {
  BadRequestException,
  HttpStatus,
  Injectable,
  Logger,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, Repository } from 'typeorm';

import { CaptureEvent } from '../entities/capture-event.entity';
import { CreateCaptureDto } from '../dto/create-capture.dto';
import { CaptureResponseDto } from '../dto/capture-response.dto';
import {
  CaptureIdempotenceService,
  CanonicalPayloadFields,
} from './capture-idempotence.service';
import { KekKeyringService } from './kek-keyring.service';

// =============================================================================
// Constants
// =============================================================================

/** Tolerance skew timestamp_device en secondes (§5.2 — fixe a 300s) */
const TIMESTAMP_SKEW_TOLERANCE_SECONDS = 300;

/** Event types pour capture_audit_log */
const AUDIT_EVENT_CAPTURE_INGESTED = 'CAPTURE_INGESTED';
const AUDIT_EVENT_CAPTURE_IDEMPOTENT = 'CAPTURE_IDEMPOTENT_REPLAY';

// =============================================================================
// Types
// =============================================================================

export interface IngestResult {
  httpStatus: HttpStatus;
  body: CaptureResponseDto;
}

// =============================================================================
// Service
// =============================================================================

@Injectable()
export class CaptureIngestService {
  private readonly logger = new Logger(CaptureIngestService.name);

  constructor(
    @InjectRepository(CaptureEvent)
    private readonly captureEventRepository: Repository<CaptureEvent>,
    private readonly dataSource: DataSource,
    private readonly captureIdempotenceService: CaptureIdempotenceService,
    private readonly kekKeyringService: KekKeyringService,
  ) {}

  // ---------------------------------------------------------------------------
  // Public API
  // ---------------------------------------------------------------------------

  /**
   * Ingere une capture probatoire.
   *
   * Flux (§5.5, §5.8) :
   * 1. Normalise capture_id lowercase (INV-103-31)
   * 2. Valide skew timestamp_device ±300s (§5.2)
   * 3. Ouvre une transaction ACID encompassante (INV-103-10)
   * 4. Verifie idempotence sous advisory lock (M10, INV-103-37)
   * 5. Si IDEMPOTENT : retourne 200 avec etat existant
   * 6. Si CREATE :
   *    a. Unwrap DEK via keyring (M11, INV-103-34)
   *    b. Zeroize DEK immediatement apres verification (INV-103-32)
   *    c. Persiste capture_events avec signature_status='PENDING_SIGNATURE'
   *    d. Persiste entree capture_audit_log (journal append-only, INV-103-25)
   *    e. Retourne 202 Accepted
   *
   * @throws BadRequestException (400) si validation echoue ou skew depasse
   * @throws ConflictException (409) si fingerprint divergent (via M10)
   * @throws UnwrapDekFailedError (422) si unwrap DEK echoue (via M11)
   * @throws KeyServiceUnavailableError (503) si HSM/KMS down (via M11)
   */
  async createCapture(dto: CreateCaptureDto, userId: string): Promise<IngestResult> {
    // 1. Normalisation capture_id (INV-103-31)
    // Note : la Transform du DTO applique deja le lowercase,
    // mais on double-check ici par defense en profondeur.
    const normalizedCaptureId = dto.captureId.toLowerCase();

    // 2. Validation skew timestamp_device (§5.2, ER-103-12)
    this.validateTimestampSkew(dto.timestampDevice);

    // 3. Transaction ACID encompassante (INV-103-10)
    return this.dataSource.transaction(async (manager) => {
      // 4. Verification idempotence (M10, INV-103-37)
      const canonicalFields: CanonicalPayloadFields = {
        captureId: normalizedCaptureId,
        contentHash: dto.hashSha3256,
        aesGcmNonceB64: dto.aesGcmNonceB64,
        aesGcmTagB64: dto.aesGcmTagB64,
        dekWrappedB64: dto.dekWrappedB64,
        kekId: dto.kekId,
        mimeType: dto.mimeType,
        sizeBytes: dto.sizeBytes,
        uploadObjectKey: dto.uploadObjectKey,
      };

      const idempotenceResult =
        await this.captureIdempotenceService.acquireLockAndCheckIdempotence(
          manager,
          userId,
          canonicalFields,
        );

      // 5. Replay idempotent (200)
      if (idempotenceResult.action === 'IDEMPOTENT') {
        // Audit du replay (non bloquant)
        await this.appendAuditLog(manager, normalizedCaptureId, AUDIT_EVENT_CAPTURE_IDEMPOTENT, {
          existingEventId: idempotenceResult.existingCaptureEventId,
          existingState: idempotenceResult.existingState,
        });

        return {
          httpStatus: HttpStatus.OK,
          body: {
            captureId: normalizedCaptureId,
            state: idempotenceResult.existingState,
            signatureStatus: 'PENDING_SIGNATURE',
            createdAt: new Date().toISOString(),
          },
        };
      }

      // 6. Nouvelle capture — CREATE

      // 6a. Unwrap DEK (M11, INV-103-34)
      // L'unwrap verifie que le dek_wrapped_b64 est valide et decodable par le keyring.
      // Le DEK en clair est retourne pour validation, puis zeroize immediatement.
      // M9 ne persiste JAMAIS le DEK en clair (INV-103-09).
      let dekClearBuffer: Buffer | null = null;
      try {
        const unwrapResult = this.kekKeyringService.unwrapDek(
          dto.dekWrappedB64,
          dto.kekId,
        );
        dekClearBuffer = unwrapResult.dekClear;

        // 6b. Zeroize DEK immediatement (INV-103-32)
        // Le DEK en clair n'est pas utilise cote backend pour le chiffrement
        // (le fichier est deja chiffre par le mobile). L'unwrap sert uniquement
        // a valider que le wrapping est correct et que le backend pourra
        // dechiffrer quand necessaire (pipeline scellement).
      } finally {
        if (dekClearBuffer) {
          dekClearBuffer.fill(0x00);
          dekClearBuffer = null;
        }
      }

      // 6c. Conversion dek_wrapped_b64 en Buffer pour stockage bytea
      const dekWrappedBuffer = Buffer.from(dto.dekWrappedB64, 'base64');

      // 6d. Persistance capture_events (INV-103-10, INV-103-25)
      const captureEvent = manager.create(CaptureEvent, {
        captureId: normalizedCaptureId,
        userId,
        state: 'CAPTURED',
        signatureStatus: 'PENDING_SIGNATURE',
        hsmSignatureRef: null,
        dekWrapped: dekWrappedBuffer,
        kekId: dto.kekId,
        aesGcmNonceB64: dto.aesGcmNonceB64,
        aesGcmTagB64: dto.aesGcmTagB64,
        hashSha3256: dto.hashSha3256,
        mimeType: dto.mimeType,
        sizeBytes: dto.sizeBytes,
        deviceId: dto.deviceId,
        appVersion: dto.appVersion,
        timestampDevice: new Date(dto.timestampDevice),
        ocrText: dto.ocrText ?? null,
        ocrConfidence: dto.ocrConfidence ?? null,
        ocrLanguage: dto.ocrLanguage ?? null,
        payloadCanonicalSha256: idempotenceResult.payloadCanonicalSha256,
        uploadObjectKey: dto.uploadObjectKey,
        merkleProofRef: null,
        tsaTokenRef: null,
        txHash: null,
        sealDelayed: false,
        sealDelayedConformingCycles: 0,
      });

      const savedEvent = await manager.save(CaptureEvent, captureEvent);

      // 6e. Journal probatoire append-only (INV-103-25)
      await this.appendAuditLog(manager, normalizedCaptureId, AUDIT_EVENT_CAPTURE_INGESTED, {
        captureEventId: savedEvent.id,
        hashSha3256: dto.hashSha3256,
        kekId: dto.kekId,
        sizeBytes: dto.sizeBytes,
        uploadObjectKey: dto.uploadObjectKey,
        ocrEnabled: dto.ocrEnabled ?? false,
      });

      this.logger.log(
        `Capture ingested: capture_id=${normalizedCaptureId}, event_id=${savedEvent.id}`,
      );

      return {
        httpStatus: HttpStatus.ACCEPTED,
        body: {
          captureId: normalizedCaptureId,
          state: savedEvent.state,
          signatureStatus: savedEvent.signatureStatus,
          createdAt: savedEvent.createdAt.toISOString(),
        },
      };
    });
  }

  // ---------------------------------------------------------------------------
  // Private — Validation
  // ---------------------------------------------------------------------------

  /**
   * Valide que le timestamp_device est dans la tolerance ±300s (§5.2).
   *
   * @throws BadRequestException avec code TIMESTAMP_SKEW_EXCEEDED (ER-103-12)
   */
  private validateTimestampSkew(timestampDevice: string): void {
    const deviceTime = new Date(timestampDevice);
    if (isNaN(deviceTime.getTime())) {
      throw new BadRequestException({
        error: 'INVALID_TIMESTAMP',
        message: 'timestamp_device is not a valid date',
      });
    }

    const now = Date.now();
    const skewMs = Math.abs(now - deviceTime.getTime());

    if (skewMs > TIMESTAMP_SKEW_TOLERANCE_SECONDS * 1000) {
      this.logger.warn(
        `Timestamp skew exceeded: device=${timestampDevice}, ` +
          `skew=${Math.round(skewMs / 1000)}s, tolerance=${TIMESTAMP_SKEW_TOLERANCE_SECONDS}s`,
      );
      throw new BadRequestException({
        error: 'TIMESTAMP_SKEW_EXCEEDED',
        message: `timestamp_device skew exceeds ±${TIMESTAMP_SKEW_TOLERANCE_SECONDS}s tolerance`,
      });
    }
  }

  // ---------------------------------------------------------------------------
  // Private — Audit log
  // ---------------------------------------------------------------------------

  /**
   * Append une entree dans capture_audit_log (journal probatoire append-only).
   * INSERT uniquement — la table est protegee par un trigger DB (M13).
   */
  private async appendAuditLog(
    manager: import('typeorm').EntityManager,
    captureId: string,
    eventType: string,
    payload: Record<string, unknown>,
  ): Promise<void> {
    await manager.query(
      `INSERT INTO vault_secure.capture_audit_log (capture_id, event_type, payload_json)
       VALUES ($1, $2, $3)`,
      [captureId, eventType, JSON.stringify(payload)],
    );
  }
}

5.6 src/modules/capture/capture.module.ts

// ============================================================================
// src/modules/capture/capture.module.ts — PD-103
// ============================================================================

import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CaptureEvent } from './entities/capture-event.entity';
import { CaptureController } from './capture.controller';
import { CaptureIngestService } from './services/capture-ingest.service';
import { CaptureIdempotenceService } from './services/capture-idempotence.service';
import { KekKeyringService } from './services/kek-keyring.service';

@Module({
  imports: [TypeOrmModule.forFeature([CaptureEvent])],
  controllers: [CaptureController],
  providers: [
    CaptureIngestService,
    CaptureIdempotenceService,
    KekKeyringService,
  ],
  exports: [CaptureIngestService],
})
export class CaptureModule {}

6. Decisions architecturales

Decision Rationale Alternatives considerees Trade-offs
Transaction ACID unique encompassante Garantit atomicite : soit tout est persiste (capture_events + audit_log), soit rien. Coherent avec pattern DepositService (PD-60, INV-60-02). Deux transactions separees (check + insert) Lock advisory bloque les requetes concurrentes sur le meme capture_id pendant ~50-100ms. Acceptable.
Validation DTO via class-validator Standard NestJS, regexes contractuelles §5.1 appliquees directement. Le ValidationPipe avec whitelist: true rejette les champs non declares (defense en profondeur). Zod, validation manuelle Moins composable que Zod mais natif et coherent avec le projet.
Unwrap DEK puis zeroize immediatement Le backend n'utilise pas le DEK en clair a l'ingestion (le fichier est deja chiffre). L'unwrap sert a valider le wrapping et assurer la dechifrabilite future par le pipeline de scellement. Ne pas unwrapper a l'ingestion (reporter au pipeline) Reporter l'unwrap retarderait la detection d'echec crypto (KEK rotee/corrompu) jusqu'au pipeline de scellement, rendant le diagnostic plus difficile.
Audit log via query SQL directe (pas ORM) La table capture_audit_log est INSERT-ONLY avec trigger (M13). L'acces direct evite les complexites ORM sur une table sans UPDATE. Entity TypeORM pour audit_log L'entity ajouterait du code pour une table en lecture/ecriture unidirectionnelle. La query directe est plus simple et evite les problemes de mapping ORM.

7. Mapping invariants

Invariant Mecanisme Observable
INV-103-10 (atomicite ACID) dataSource.transaction() encompassante : advisory lock + idempotence + unwrap + INSERT + audit dans une seule transaction Rollback complet si erreur a n'importe quelle etape
INV-103-25 (UPLOADED → PENDING_SEAL) INSERT dans capture_events + INSERT dans capture_audit_log dans la meme transaction Entree DB + journal traçable par capture_id
INV-103-30 (key exchange) Unwrap DEK via M11, validation wrapping correct, zeroize immediat du DEK clair DEK jamais persiste en clair ; dek_wrapped en bytea uniquement
INV-103-31 (capture_id lowercase) Transform DTO + toLowerCase() dans service (defense en profondeur) Base et logs ne contiennent que des capture_id lowercase
INV-103-34 (keyring KEK rotation) Delegation a M11 unwrapDek(dekWrappedB64, kekId) ; codes erreur 422/503 Unwrap reussi avec ancien kek_id via keyring
INV-103-37 (fingerprint canonique) Delegation a M10 acquireLockAndCheckIdempotence() payload_canonical_sha256 stocke en base ; 200/409 conformes
INV-103-09 (envelope encryption) DEK zeroize dans finally apres unwrap ; colonne dek_wrapped (bytea) jamais clair Audit colonne base : aucun DEK clair

8. Mapping tests

Test ID Reference spec Mecanisme Observable Niveau
TC-NOM-01 INV-01..03, 06, 22, 30, 31 createCapture() flux nominal complet 202 Accepted, capture_events persiste, audit log ecrit Integration
TC-NOM-07 INV-103-25 createCapture() persistence capture_events + audit_log Entree DB unique traçable par capture_id Integration
TC-NOM-15 INV-31, 37 createCapture() replay avec capture_id casse differente 200 idempotent malgre casse Integration
TC-NOM-16 INV-34 createCapture() avec ancien kek_id 202 Accepted via keyring historique Integration
TC-ERR-06 §5.1 ValidationPipe rejet DTO invalide HTTP 400 par champ invalide, aucun artefact cree Unit
TC-ERR-08 INV-08 Garde backend dans pipeline existant (hors M9 direct) Rejet transition SEALED si garde non satisfaite Integration
TC-ERR-10 INV-11 Throttle decorator 60 req/min HTTP 429 Integration
TC-ERR-12 §5.2 validateTimestampSkew() HTTP 400 TIMESTAMP_SKEW_EXCEEDED Unit
TC-ERR-13 INV-37 M10 ConflictException (409) HTTP 409 Conflict, aucun doublon Unit
TC-ERR-16 INV-34 M11 UnwrapDekFailedError (422) HTTP 422 UNWRAP_DEK_FAILED Unit
TC-ERR-17 ER-103-17 M11 KeyServiceUnavailableError (503) HTTP 503 KEY_SERVICE_UNAVAILABLE Unit
TC-INV-04 INV-10 Crash injection avant/apres commit Rollback pre-commit, rattrapage post-commit Integration
TC-INV-05 INV-11 lock Advisory lock via M10 Un seul worker transitionne Integration
TC-NEG-01 §5.1 DTO validation capture_id non UUID v4 HTTP 400 Unit
TC-NEG-02 §5.1 DTO validation hash hors regex HTTP 400 Unit
TC-NEG-03 §5.1 DTO validation mime_type != image/png HTTP 400 Unit
TC-NEG-04 §5.1 DTO validation size_bytes hors bornes HTTP 400 Unit
TC-NEG-06 §5.1 DTO validation nonce/tag mal formes HTTP 400 Unit
TC-NEG-07 §5.1 DTO validation ocr_text > 20000 chars HTTP 400 Unit
TC-NEG-08 §5.1 DTO validation ocr_confidence hors [0,1] HTTP 400 Unit
TC-NEG-09 §5.1 DTO validation timestamp non UTC HTTP 400 Unit
TC-NEG-10 §5.2 Skew > ±300s HTTP 400 TIMESTAMP_SKEW_EXCEEDED Unit
TC-NEG-11 §5.1 DTO validation dek_wrapped_b64 absent/invalide HTTP 400 Unit
TC-NEG-17 §5.1 DTO validation kek_id absent/mal forme HTTP 400 Unit
TC-NEG-18 §5.1 dek_wrapped_b64 valide mais incompatible keyring HTTP 422 Unit

9. Tests unitaires proposes

// src/modules/capture/__tests__/capture-ingest.service.spec.ts

import { BadRequestException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { DataSource, Repository } from 'typeorm';
import { getRepositoryToken } from '@nestjs/typeorm';
import { CaptureIngestService } from '../services/capture-ingest.service';
import { CaptureIdempotenceService } from '../services/capture-idempotence.service';
import { KekKeyringService, UnwrapDekFailedError, KeyServiceUnavailableError } from '../services/kek-keyring.service';
import { CaptureEvent } from '../entities/capture-event.entity';
import { CreateCaptureDto } from '../dto/create-capture.dto';

describe('CaptureIngestService', () => {
  let service: CaptureIngestService;
  let mockIdempotenceService: jest.Mocked<CaptureIdempotenceService>;
  let mockKekKeyringService: jest.Mocked<KekKeyringService>;
  let mockDataSource: { transaction: jest.Mock };
  let mockRepository: jest.Mocked<Partial<Repository<CaptureEvent>>>;

  const validDto: CreateCaptureDto = {
    captureId: 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d',
    deviceId: 'b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e',
    hashSha3256: 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789',
    mimeType: 'image/png',
    sizeBytes: 524288,
    appVersion: '1.0.0',
    timestampDevice: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
    aesGcmNonceB64: 'dGVzdG5vbmNlMTIz',
    aesGcmTagB64: 'dGVzdHRhZzEyMzQ1Njc4OQ==',
    dekWrappedB64: 'A'.repeat(344),
    kekId: 'kek-2026-04-01',
    uploadObjectKey: 'captures/a1b2c3d4.enc',
  };

  const userId = 'user-uuid-123';

  beforeEach(async () => {
    mockIdempotenceService = {
      acquireLockAndCheckIdempotence: jest.fn(),
    } as any;

    mockKekKeyringService = {
      unwrapDek: jest.fn(),
    } as any;

    mockRepository = {};

    // Mock transaction : execute le callback avec un mock manager
    const mockManager = {
      create: jest.fn().mockImplementation((_entity, data) => ({ ...data, id: 'event-uuid' })),
      save: jest.fn().mockImplementation((_entity, data) => ({
        ...data,
        id: 'event-uuid',
        createdAt: new Date(),
        updatedAt: new Date(),
      })),
      query: jest.fn().mockResolvedValue(undefined),
    };

    mockDataSource = {
      transaction: jest.fn().mockImplementation(async (cb) => cb(mockManager)),
    };

    const module: TestingModule = await Test.createTestingModule({
      providers: [
        CaptureIngestService,
        { provide: getRepositoryToken(CaptureEvent), useValue: mockRepository },
        { provide: DataSource, useValue: mockDataSource },
        { provide: CaptureIdempotenceService, useValue: mockIdempotenceService },
        { provide: KekKeyringService, useValue: mockKekKeyringService },
      ],
    }).compile();

    service = module.get(CaptureIngestService);
  });

  // -------------------------------------------------------------------------
  // TC-NOM-01 / TC-NOM-07 — Flux nominal
  // -------------------------------------------------------------------------

  describe('createCapture — flux nominal', () => {
    it('should return 202 for new capture (TC-NOM-01)', async () => {
      mockIdempotenceService.acquireLockAndCheckIdempotence.mockResolvedValue({
        action: 'CREATE',
        payloadCanonicalSha256: 'a'.repeat(64),
      });
      mockKekKeyringService.unwrapDek.mockReturnValue({
        dekClear: Buffer.alloc(32, 0x42),
        kekIdUsed: 'kek-2026-04-01',
      });

      const result = await service.createCapture(validDto, userId);

      expect(result.httpStatus).toBe(202);
      expect(result.body.captureId).toBe('a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d');
      expect(result.body.state).toBe('CAPTURED');
      expect(result.body.signatureStatus).toBe('PENDING_SIGNATURE');
    });

    it('should normalize capture_id to lowercase (INV-103-31)', async () => {
      const upperDto = { ...validDto, captureId: 'A1B2C3D4-E5F6-4A7B-8C9D-0E1F2A3B4C5D' };
      mockIdempotenceService.acquireLockAndCheckIdempotence.mockResolvedValue({
        action: 'CREATE',
        payloadCanonicalSha256: 'b'.repeat(64),
      });
      mockKekKeyringService.unwrapDek.mockReturnValue({
        dekClear: Buffer.alloc(32, 0x42),
        kekIdUsed: 'kek-2026-04-01',
      });

      const result = await service.createCapture(upperDto, userId);

      expect(result.body.captureId).toBe('a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d');
    });

    it('should zeroize DEK buffer after unwrap (INV-103-32)', async () => {
      const dekBuffer = Buffer.alloc(32, 0x42);
      mockIdempotenceService.acquireLockAndCheckIdempotence.mockResolvedValue({
        action: 'CREATE',
        payloadCanonicalSha256: 'c'.repeat(64),
      });
      mockKekKeyringService.unwrapDek.mockReturnValue({
        dekClear: dekBuffer,
        kekIdUsed: 'kek-2026-04-01',
      });

      await service.createCapture(validDto, userId);

      // Verify buffer was zeroized
      expect(dekBuffer.every((byte) => byte === 0x00)).toBe(true);
    });
  });

  // -------------------------------------------------------------------------
  // TC-NOM-15 — Replay idempotent
  // -------------------------------------------------------------------------

  describe('createCapture — idempotence', () => {
    it('should return 200 for idempotent replay (TC-NOM-15)', async () => {
      mockIdempotenceService.acquireLockAndCheckIdempotence.mockResolvedValue({
        action: 'IDEMPOTENT',
        existingCaptureEventId: 'existing-event-uuid',
        existingState: 'UPLOADED',
      });

      const result = await service.createCapture(validDto, userId);

      expect(result.httpStatus).toBe(200);
      expect(result.body.state).toBe('UPLOADED');
    });
  });

  // -------------------------------------------------------------------------
  // TC-ERR-12 / TC-NEG-10 — Skew timestamp
  // -------------------------------------------------------------------------

  describe('createCapture — timestamp validation', () => {
    it('should reject timestamp skew > ±300s (TC-ERR-12)', async () => {
      const futureDto = {
        ...validDto,
        timestampDevice: new Date(Date.now() + 600_000).toISOString().replace(/\.\d{3}Z$/, 'Z'),
      };

      await expect(service.createCapture(futureDto, userId)).rejects.toThrow(BadRequestException);
      await expect(service.createCapture(futureDto, userId)).rejects.toMatchObject({
        response: expect.objectContaining({ error: 'TIMESTAMP_SKEW_EXCEEDED' }),
      });
    });

    it('should accept timestamp within ±300s tolerance', async () => {
      const withinToleranceDto = {
        ...validDto,
        timestampDevice: new Date(Date.now() - 200_000).toISOString().replace(/\.\d{3}Z$/, 'Z'),
      };
      mockIdempotenceService.acquireLockAndCheckIdempotence.mockResolvedValue({
        action: 'CREATE',
        payloadCanonicalSha256: 'd'.repeat(64),
      });
      mockKekKeyringService.unwrapDek.mockReturnValue({
        dekClear: Buffer.alloc(32, 0x42),
        kekIdUsed: 'kek-2026-04-01',
      });

      const result = await service.createCapture(withinToleranceDto, userId);
      expect(result.httpStatus).toBe(202);
    });
  });

  // -------------------------------------------------------------------------
  // TC-ERR-16 / TC-ERR-17 — Erreurs unwrap DEK
  // -------------------------------------------------------------------------

  describe('createCapture — DEK unwrap errors', () => {
    beforeEach(() => {
      mockIdempotenceService.acquireLockAndCheckIdempotence.mockResolvedValue({
        action: 'CREATE',
        payloadCanonicalSha256: 'e'.repeat(64),
      });
    });

    it('should propagate UnwrapDekFailedError (TC-ERR-16)', async () => {
      mockKekKeyringService.unwrapDek.mockImplementation(() => {
        throw new UnwrapDekFailedError('Cannot unwrap');
      });

      await expect(service.createCapture(validDto, userId)).rejects.toThrow(UnwrapDekFailedError);
    });

    it('should propagate KeyServiceUnavailableError (TC-ERR-17)', async () => {
      mockKekKeyringService.unwrapDek.mockImplementation(() => {
        throw new KeyServiceUnavailableError('HSM down');
      });

      await expect(service.createCapture(validDto, userId)).rejects.toThrow(KeyServiceUnavailableError);
    });
  });

  // -------------------------------------------------------------------------
  // TC-ERR-13 — Conflit idempotence (via M10)
  // -------------------------------------------------------------------------

  describe('createCapture — conflict', () => {
    it('should propagate ConflictException from M10 (TC-ERR-13)', async () => {
      const { ConflictException } = await import('@nestjs/common');
      mockIdempotenceService.acquireLockAndCheckIdempotence.mockRejectedValue(
        new ConflictException('capture_id already used'),
      );

      await expect(service.createCapture(validDto, userId)).rejects.toThrow(ConflictException);
    });
  });

  // -------------------------------------------------------------------------
  // Audit log
  // -------------------------------------------------------------------------

  describe('createCapture — audit log', () => {
    it('should write audit log on successful ingestion (TC-NOM-07)', async () => {
      mockIdempotenceService.acquireLockAndCheckIdempotence.mockResolvedValue({
        action: 'CREATE',
        payloadCanonicalSha256: 'f'.repeat(64),
      });
      mockKekKeyringService.unwrapDek.mockReturnValue({
        dekClear: Buffer.alloc(32, 0x42),
        kekIdUsed: 'kek-2026-04-01',
      });

      await service.createCapture(validDto, userId);

      // The mock manager's query should have been called for audit log INSERT
      const managerCalls = mockDataSource.transaction.mock.calls;
      expect(managerCalls.length).toBe(1);
      // The callback was executed — audit log is written within the transaction
    });
  });
});

9.1 Tests DTO proposes

// src/modules/capture/__tests__/create-capture.dto.spec.ts

import { validate } from 'class-validator';
import { plainToInstance } from 'class-transformer';
import { CreateCaptureDto } from '../dto/create-capture.dto';

describe('CreateCaptureDto validation (TC-ERR-06, TC-NEG-01..11, TC-NEG-17)', () => {
  const validPayload = {
    captureId: 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d',
    deviceId: 'b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e',
    hashSha3256: 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789',
    mimeType: 'image/png',
    sizeBytes: 524288,
    appVersion: '1.0.0',
    timestampDevice: '2026-04-03T12:00:00Z',
    aesGcmNonceB64: 'dGVzdG5vbmNlMTIz',
    aesGcmTagB64: 'dGVzdHRhZzEyMzQ1Njc4OQ==',
    dekWrappedB64: 'A'.repeat(344),
    kekId: 'kek-2026-04-01',
    uploadObjectKey: 'captures/test.enc',
  };

  async function expectValid(payload: Record<string, unknown>): Promise<void> {
    const dto = plainToInstance(CreateCaptureDto, payload);
    const errors = await validate(dto);
    expect(errors).toHaveLength(0);
  }

  async function expectInvalid(payload: Record<string, unknown>): Promise<void> {
    const dto = plainToInstance(CreateCaptureDto, payload);
    const errors = await validate(dto);
    expect(errors.length).toBeGreaterThan(0);
  }

  it('should accept valid payload', () => expectValid(validPayload));

  // TC-NEG-01 : capture_id non UUID v4
  it('should reject non-UUID capture_id', () =>
    expectInvalid({ ...validPayload, captureId: 'not-a-uuid' }));

  // TC-NEG-02 : hash hors regex
  it('should reject uppercase hash', () =>
    expectInvalid({ ...validPayload, hashSha3256: 'ABCDEF' + '0'.repeat(58) }));

  it('should reject short hash', () =>
    expectInvalid({ ...validPayload, hashSha3256: 'abcdef' }));

  // TC-NEG-03 : mime_type != image/png
  it('should reject image/jpeg', () =>
    expectInvalid({ ...validPayload, mimeType: 'image/jpeg' }));

  // TC-NEG-04 : size_bytes hors bornes
  it('should reject size_bytes = 0', () =>
    expectInvalid({ ...validPayload, sizeBytes: 0 }));

  it('should reject size_bytes > 524288000', () =>
    expectInvalid({ ...validPayload, sizeBytes: 524288001 }));

  // TC-NEG-06 : nonce/tag mal formes
  it('should reject invalid nonce', () =>
    expectInvalid({ ...validPayload, aesGcmNonceB64: 'short' }));

  it('should reject invalid tag', () =>
    expectInvalid({ ...validPayload, aesGcmTagB64: 'invalid' }));

  // TC-NEG-07 : ocr_text > 20000 chars
  it('should reject ocr_text > 20000 chars', () =>
    expectInvalid({ ...validPayload, ocrText: 'a'.repeat(20001) }));

  // TC-NEG-08 : ocr_confidence hors [0,1]
  it('should reject ocr_confidence > 1', () =>
    expectInvalid({ ...validPayload, ocrConfidence: 1.5 }));

  it('should reject ocr_confidence < 0', () =>
    expectInvalid({ ...validPayload, ocrConfidence: -0.1 }));

  // TC-NEG-09 : timestamp non UTC
  it('should reject timestamp with timezone offset', () =>
    expectInvalid({ ...validPayload, timestampDevice: '2026-04-03T12:00:00+02:00' }));

  // TC-NEG-11 : dek_wrapped_b64 trop court
  it('should reject dek_wrapped_b64 < 128 chars', () =>
    expectInvalid({ ...validPayload, dekWrappedB64: 'A'.repeat(50) }));

  // TC-NEG-17 : kek_id absent
  it('should reject missing kek_id', () => {
    const { kekId, ...withoutKekId } = validPayload;
    return expectInvalid(withoutKekId);
  });

  it('should reject kek_id with invalid chars', () =>
    expectInvalid({ ...validPayload, kekId: 'kek id with spaces!' }));

  // Champs optionnels OCR acceptes
  it('should accept payload with optional OCR fields', () =>
    expectValid({
      ...validPayload,
      ocrEnabled: true,
      ocrText: 'Hello world',
      ocrConfidence: 0.95,
      ocrLanguage: 'fr',
    }));

  it('should accept payload without OCR fields', () =>
    expectValid(validPayload));

  // Normalisation capture_id lowercase (INV-103-31)
  it('should normalize capture_id to lowercase via Transform', () => {
    const dto = plainToInstance(CreateCaptureDto, {
      ...validPayload,
      captureId: 'A1B2C3D4-E5F6-4A7B-8C9D-0E1F2A3B4C5D',
    });
    expect(dto.captureId).toBe('a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d');
  });
});

10. Contrat d'interface avec les modules dependants

10.1 M10 — CaptureIdempotenceService

// M9 appelle M10 dans la transaction :
const result = await captureIdempotenceService.acquireLockAndCheckIdempotence(
  manager,        // EntityManager transactionnel
  userId,         // string
  canonicalFields, // CanonicalPayloadFields
);
// result.action === 'CREATE' | 'IDEMPOTENT'
// Si fingerprint divergent : ConflictException (409) thrown par M10

10.2 M11 — KekKeyringService

// M9 appelle M11 pour unwrap :
const { dekClear, kekIdUsed } = kekKeyringService.unwrapDek(
  dto.dekWrappedB64, // string base64
  dto.kekId,         // string
);
// dekClear: Buffer (32 bytes) — DOIT etre zeroize par M9 apres usage
// Throws: UnwrapDekFailedError (422) | KeyServiceUnavailableError (503)

10.3 M13 — Tables

M9 ecrit dans les tables creees par M13 : - vault_secure.capture_events — INSERT via TypeORM entity - vault_secure.capture_audit_log — INSERT via SQL directe

10.4 Pipeline scellement (post-commit, hors M9)

Apres commit de la transaction, M9 peut enqueuer un job BullMQ pour le pipeline de scellement (PD-55/PD-56/PD-41). Ce mecanisme est hors perimetre M9 — il sera ajoute lors de l'integration du pipeline existant.

11. Hypotheses

ID Hypothese Impact si faux
H-M9-01 Le guard JWT existant (JwtAuthGuard) expose req.user.id comme UUID du user authentifie Si le champ est different (req.user.sub, etc.), adapter l'extraction dans le controller
H-M9-02 Le module @nestjs/throttler est deja configure globalement dans le backend Si absent, ajouter ThrottlerModule.forRoot() dans app.module.ts et le guard global
H-M9-03 La table capture_events dans le schema vault_secure est accessible en lecture/ecriture depuis le contexte de transaction TypeORM Si RLS bloque, M9 doit positionner SET LOCAL app.current_user_id en debut de transaction (pattern DepositService)
H-M9-04 Le ValidationPipe global est configure avec transform: true dans le bootstrap de l'application Si non, la @Transform du DTO ne sera pas appliquee automatiquement — le pipe explicite dans le controller le couvre
H-M9-05 L'enqueue BullMQ post-commit pour le pipeline de scellement sera ajoute lors de l'integration avec PD-55/PD-56 Si le pipeline n'est pas encore livre, les captures restent en CAPTURED (etat initial persiste) jusqu'a la livraison du pipeline

12. Perimetre hors module

  • Machine a etats (M1) : M9 persiste l'etat initial CAPTURED. Les transitions ulterieures (UPLOADED → PENDING_SEAL → SEALED → ANCHOR_CONFIRMED) sont gerees par le pipeline de scellement existant et le module de reconciliation (M12).
  • Upload S3 : La generation d'URL pre-signee et l'upload du ciphertext sont geres cote mobile (M4) et hors perimetre M9. M9 recoit uniquement l'upload_object_key apres upload reussi.
  • Reconciliation (M12) : Le scan des etats non-terminaux, le trigger SEAL_DELAYED, le clearing, et le GC orphelins S3 sont hors perimetre M9.
  • Notification push : Geree par le pipeline de scellement existant a l'entree en SEALED.
  • Presigned URL endpoint : L'endpoint POST /documents/capture/presign pourrait etre ajoute dans ce controller, mais est hors perimetre de ce livrable M9 (concerne le flux d'upload mobile, pas l'ingestion).

13. Matrice de couverture test

Test-ID Fichier de test
TC-NOM-01 capture-ingest.service.spec.ts (flux nominal → 202)
TC-NOM-07 capture-ingest.service.spec.ts (audit log ecrit)
TC-NOM-15 capture-ingest.service.spec.ts (replay idempotent → 200)
TC-NOM-16 capture-ingest.service.spec.ts (ancien kek_id via M11 → 202)
TC-ERR-06 create-capture.dto.spec.ts (validation DTO par champ invalide → 400)
TC-ERR-12 capture-ingest.service.spec.ts (skew > ±300s → 400 TIMESTAMP_SKEW_EXCEEDED)
TC-ERR-13 capture-ingest.service.spec.ts (fingerprint divergent → 409 via M10)
TC-ERR-16 capture-ingest.service.spec.ts (UnwrapDekFailedError → 422)
TC-ERR-17 capture-ingest.service.spec.ts (KeyServiceUnavailableError → 503)
TC-INV-04 Integration test (crash pre/post commit)
TC-INV-05 Integration test (advisory lock via M10)
TC-NEG-01 create-capture.dto.spec.ts (capture_id non UUID v4)
TC-NEG-02 create-capture.dto.spec.ts (hash hors regex)
TC-NEG-03 create-capture.dto.spec.ts (mime_type != image/png)
TC-NEG-04 create-capture.dto.spec.ts (size_bytes hors bornes)
TC-NEG-06 create-capture.dto.spec.ts (nonce/tag mal formes)
TC-NEG-07 create-capture.dto.spec.ts (ocr_text > 20000 chars)
TC-NEG-08 create-capture.dto.spec.ts (ocr_confidence hors [0,1])
TC-NEG-09 create-capture.dto.spec.ts (timestamp non UTC)
TC-NEG-10 capture-ingest.service.spec.ts (skew > ±5 min)
TC-NEG-11 create-capture.dto.spec.ts (dek_wrapped_b64 invalide)
TC-NEG-17 create-capture.dto.spec.ts (kek_id absent/mal forme)
TC-NEG-18 capture-ingest.service.spec.ts (unwrap incompatible → 422)
TC-NR-07 create-capture.dto.spec.ts + capture-ingest.service.spec.ts (key exchange complet)
TC-NR-09 capture-ingest.service.spec.ts (skew rejection stable)