Aller au contenu

PD-278 — Plan d'implémentation (v2)

Changelog v1 → v2 : 19 écarts corrigés (3 BLQ + 9 MAJ + 7 MIN). Ajout table attestation (BLQ-01), audit refus synchrone (BLQ-02), alignement enum DB 5 valeurs (BLQ-03), codes 503/404 (MAJ-01/02), capture 401 pre-controller (MAJ-03), rétention attestations 10 ans (MAJ-04), convention verrouillage cross-module (MAJ-05), validation H-06 phase 0 (MAJ-06), BYPASSRLS contractualisé (MAJ-07), compatibilité ESM/CJS (MAJ-08), code contracts alignés spec (MAJ-09), 7 corrections mineures (MIN-01→07).


1. Découpage en composants

Vue d'ensemble

ID Composant Responsabilité Fichiers cibles Dépendances
C1 Migration DDL Ajout DIP à l'enum PostgreSQL, colonnes, index, trigger WORM, CHECK constraints, table vault_secure.dissemination_attestations src/database/migrations/1741400000000-PD278-AddDipState.ts Aucune
C2 Extension entities TypeScript Ajout DIP = 'DIP' dans DocumentStatus + colonnes entity + entity DisseminationAttestation src/modules/documents/entities/document-secure.entity.ts, src/modules/documents/entities/dissemination-attestation.entity.ts C1
C3 Extension machine à états Ajout SEALED → DIP et DIP → SEALED dans ALLOWED_TRANSITIONS src/modules/destruction/services/document-state-machine.service.ts C2
C4 Configuration DIP Bornes numériques (MIN_COPIES, N_MAX, SLA, rate-limit, quota), Joi validation src/modules/documents/config/dissemination.config.ts Aucune
C5 DTOs et codes d'erreur Request/Response DTOs, error codes contractuels (E-400 à E-503) src/modules/documents/dto/dissemination*.dto.ts, src/modules/documents/dto/dissemination-error.dto.ts Aucune
C6 Extension types audit AuditActionType: DOCUMENT_DISSEMINATED, DOCUMENT_RETURNED, DOCUMENT_DISSEMINATION_DENIED src/modules/audit/types/audit-action.types.ts Aucune
C7 Extension types journal JournalEventType: DIP_DISSEMINATED, DIP_RETURNED src/modules/integrity/enums/journal-event-type.enum.ts Aucune
C8 Rate-limit guard DIP Guard rate-limit (60 req/min) + quota journalier (1000 req/jour UTC) par acteur, audit refus 429 src/modules/documents/guards/dissemination-rate-limit.guard.ts C4, C6
C9 Service DIP (core métier) Orchestration SEALED↔DIP : gardes séquentielles, atomicité ACID, attestation, outbox audit, package multi-documents, verrouillage ordonné src/modules/documents/services/dissemination.service.ts C2, C3, C4, C5, C6, C7
C10 Controller DIP 2 endpoints REST, validation entrée, AuthGuard, roles, ParseUUIDPipe src/modules/documents/controllers/dissemination.controller.ts C5, C8, C9
C11 Exception filter audit refus Capture 401/403/429/409-RETENTION-DUE pour audit DOCUMENT_DISSEMINATION_DENIED synchrone via QueryRunner dédié src/modules/documents/filters/dissemination-audit-exception.filter.ts C6
C12 Modèle formel TLA+ Ajout DIP dans DocStates, résolution DocStates ⊆ RealStates ProbatioVault-doc/docs/normes/nf-z42-013/formal/NF_Z42_013.tla Aucune
C13 Tests Unitaires + intégration couvrant TC-NOM, TC-ERR, TC-INV, TC-NR, TC-NEG, TC-FML. Framework : Jest + ts-jest. src/modules/documents/**/*.spec.ts C1–C12

Phases d'ordonnancement

Phase 0 — Validation préalable
  Validation H-06 : vérifier que geo_copy_count existe dans DocumentSecure entity
  et est maintenu par la couche stockage (PD-279). Si absent ou stub, 
  documenter le gap avec story destination avant de poursuivre.
  Vérification : entité, migration, tests existants sur geo_copy_count.

Phase 1 — Fondations (C1, C2, C3, C4, C5, C6, C7)
  Migration + enum + entity attestation + machine à états + config + DTOs + types audit/journal
  Parallélisable : C4/C5/C6/C7 sont indépendants entre eux.

Phase 2 — Core métier (C8, C9, C10, C11)
  Rate-limit guard + Service DIP + Controller + Exception filter
  Séquençage : C8 → C9 → C10/C11 (C10 et C11 parallélisables).

Phase 3 — Vérification formelle (C12)
  TLA+ alignment (indépendant du code applicatif).

Phase 4 — Tests (C13)
  Unitaires + intégration sur tous les composants.
  Framework : Jest + ts-jest (transformer TypeScript).

2. Flux techniques

2.1 Flux F1 — Communication AIP vers DIP (POST /api/v1/documents/disseminations)

Client → [JwtAuthGuard] → [AuthorizationGuard(PA,SA,auditor)]
       → [DisseminationRateLimitGuard (60/min + 1000/jour)]
       → [DisseminationAuditExceptionFilter]
       → DisseminationController.disseminate()
         → Validation DTO (ParseUUIDPipe, class-validator)
           - documents[]: UUID[], 1..100
           - motif_communication?: string, 0..1024
           - Rejet si champs serveur fournis (dissemination_package_id, timestamps)
         → DisseminationService.disseminate(dto, actorId)
           → QueryRunner.startTransaction()
             → SET LOCAL app.current_user_id = actorId  (RLS context)
             → SELECT FOR UPDATE ... ORDER BY id ASC  (verrouillage ordonné)
               Pour chaque document :
                 → Guard état : status = SEALED ? (sinon E-409-STATE)
                 → Guard copies : geo_copy_count >= MIN_COPIES ? (sinon E-422-GUARD-COPIES)
                 → Guard retention : retention_due = false ? (sinon E-409-RETENTION-DUE)
               Si un document échoue → ROLLBACK global (INV-278-11)
             → Génération dissemination_package_id (si multi: crypto.randomUUID(), si mono: NULL)
             → UPDATE status = DIP, disseminated_at = NOW(), disseminated_by = actorId,
                      dissemination_package_id, motif_communication
             → INSERT attestation dans vault_secure.dissemination_attestations (même tx)
             → INSERT outbox audit DOCUMENT_DISSEMINATED (même transaction)
           → QueryRunner.commitTransaction()
           → AuditLogService.logAsync(DOCUMENT_DISSEMINATED) (post-commit, non-bloquant)
         ← 201 Created + attestation response

2.2 Flux F2 — Retour DIP vers SEALED (POST /api/v1/documents/{id}/dissemination-return)

Client → [JwtAuthGuard] → [AuthorizationGuard(PA,SA,auditor,retention_service)]
       → [DisseminationAuditExceptionFilter]
       → DisseminationController.returnFromDissemination(documentId)
         → DisseminationService.returnFromDissemination(documentId, actorId)
           → QueryRunner.startTransaction()
             → SET LOCAL app.current_user_id = actorId  (RLS context)
             → SELECT FOR UPDATE WHERE id = documentId
               → Guard état : status = DIP ? (sinon E-409-STATE)
             → dissemination_returned_at = max(NOW(), disseminated_at) (INV-278-13)
             → UPDATE status = SEALED, dissemination_returned_at
             → INSERT outbox audit DOCUMENT_RETURNED (même transaction)
           → QueryRunner.commitTransaction()
           → AuditLogService.logAsync(DOCUMENT_RETURNED) (post-commit)
         ← 200 OK

2.3 Flux de refus sécurité (audit SYNCHRONE — spec §5.8)

Requête → Guard rejette (401/403/429/409-RETENTION-DUE)
       → DisseminationAuditExceptionFilter.catch()
         → QueryRunner dédié (transaction légère)
           → BEGIN
           → INSERT audit DOCUMENT_DISSEMINATION_DENIED (
               actor_id, document_id si résolu, reason_code, http_status
             )
           → COMMIT
         → Persistance GARANTIE avant retour de la réponse HTTP
       ← HTTP error response

Mécanisme de capture 401 pre-controller (MAJ-03) : Les guards JwtAuthGuard et AuthorizationGuard sont appliqués via @UseGuards() au niveau du controller. Dans NestJS, l'exception zone d'un controller inclut ses guards décorés — le @UseFilters(DisseminationAuditExceptionFilter) sur le controller capture donc les exceptions levées par ses guards. Aucun APP_GUARD global n'est nécessaire car les guards sont scopés au controller DIP. Si un 401 survient en amont d'un autre controller (hors scope DIP), il n'est pas audité par ce filter (cohérent avec le périmètre).

2.4 Flux anti-contournement retention (INV-278-14)

Retention Orchestrator détecte retention_due = true sur document DIP
  → Appel interne POST /api/v1/documents/{id}/dissemination-return
    (acteur = retention_service, rôle technique avec BYPASSRLS)
  → DIP → SEALED (audité DOCUMENT_RETURNED)
  → Puis rétention existante applique SEALED → EXPIRED

BYPASSRLS pour retention_service : Le rôle technique retention_service possède BYPASSRLS au niveau PostgreSQL (pattern REX PD-16). Cela lui permet d'effectuer la clôture DIP→SEALED cross-owner sans être bloqué par les politiques RLS. Ce privilège est strictement limité au rôle retention_service et audité systématiquement (DOCUMENT_RETURNED).


2.5 Diagrammes Mermaid

2.5.1 Graphe de dependances des composants

graph TD
    C1["C1 — Migration DDL"]
    C2["C2 — Extension entities TypeScript"]
    C3["C3 — Extension machine a etats"]
    C4["C4 — Configuration DIP"]
    C5["C5 — DTOs et codes d'erreur"]
    C6["C6 — Extension types audit"]
    C7["C7 — Extension types journal"]
    C8["C8 — Rate-limit guard DIP"]
    C9["C9 — Service DIP (core metier)"]
    C10["C10 — Controller DIP"]
    C11["C11 — Exception filter audit refus"]
    C12["C12 — Modele formel TLA+"]
    C13["C13 — Tests"]

    C2 --> C1
    C3 --> C2
    C8 --> C4
    C8 --> C6
    C9 --> C2
    C9 --> C3
    C9 --> C4
    C9 --> C5
    C9 --> C6
    C9 --> C7
    C10 --> C5
    C10 --> C8
    C10 --> C9
    C11 --> C6
    C13 --> C1
    C13 --> C2
    C13 --> C3
    C13 --> C4
    C13 --> C5
    C13 --> C6
    C13 --> C7
    C13 --> C8
    C13 --> C9
    C13 --> C10
    C13 --> C11
    C13 --> C12

    subgraph "Phase 1 — Fondations"
        C1
        C2
        C3
        C4
        C5
        C6
        C7
    end

    subgraph "Phase 2 — Core metier"
        C8
        C9
        C10
        C11
    end

    subgraph "Phase 3 — Verification formelle"
        C12
    end

    subgraph "Phase 4 — Tests"
        C13
    end

2.5.2 Diagramme de sequence — Flux F1 (SEALED vers DIP)

sequenceDiagram
    participant Client
    participant JwtAuthGuard
    participant AuthorizationGuard
    participant RateLimitGuard as C8 DisseminationRateLimitGuard
    participant AuditFilter as C11 DisseminationAuditExceptionFilter
    participant Controller as C10 DisseminationController
    participant Service as C9 DisseminationService
    participant StateMachine as C3 DocumentStateMachineService
    participant DB as PostgreSQL (vault_secure)
    participant Redis
    participant AuditLog as AuditLogService

    Client->>JwtAuthGuard: POST /api/v1/documents/disseminations
    JwtAuthGuard->>AuthorizationGuard: Token valide
    AuthorizationGuard->>RateLimitGuard: Role autorise (PA/SA/auditor)
    RateLimitGuard->>Redis: INCR dip:rate:{actorId}
    Redis-->>RateLimitGuard: count <= 60/min, quota <= 1000/jour
    RateLimitGuard->>Controller: Requete autorisee
    Controller->>Controller: Validation DTO (ParseUUIDPipe, class-validator)
    Controller->>Service: disseminate(dto, actorId)

    Service->>DB: BEGIN TRANSACTION
    Service->>DB: SET LOCAL app.current_user_id = actorId (RLS)
    Service->>DB: SELECT FOR UPDATE ... ORDER BY id ASC
    DB-->>Service: Documents verrouilles

    loop Pour chaque document
        Service->>StateMachine: Garde etat = SEALED ?
        Service->>Service: Garde geo_copy_count >= MIN_COPIES ?
        Service->>Service: Garde retention_due = false ?
    end

    Service->>DB: UPDATE status = DIP, disseminated_at, disseminated_by, package_id, motif
    Service->>DB: INSERT INTO dissemination_attestations (meme tx)
    Service->>DB: INSERT outbox audit DOCUMENT_DISSEMINATED (meme tx)
    Service->>DB: COMMIT

    Service->>AuditLog: logAsync(DOCUMENT_DISSEMINATED) (post-commit)
    Service-->>Controller: Attestation response
    Controller-->>Client: 201 Created + attestation

2.5.3 Diagramme de sequence — Flux F2 (DIP vers SEALED)

sequenceDiagram
    participant Client
    participant JwtAuthGuard
    participant AuthorizationGuard
    participant AuditFilter as C11 DisseminationAuditExceptionFilter
    participant Controller as C10 DisseminationController
    participant Service as C9 DisseminationService
    participant StateMachine as C3 DocumentStateMachineService
    participant DB as PostgreSQL (vault_secure)
    participant AuditLog as AuditLogService

    Client->>JwtAuthGuard: POST /api/v1/documents/{id}/dissemination-return
    JwtAuthGuard->>AuthorizationGuard: Token valide
    AuthorizationGuard->>Controller: Role autorise (PA/SA/auditor/retention_service)
    Controller->>Service: returnFromDissemination(documentId, actorId)

    Service->>DB: BEGIN TRANSACTION
    Service->>DB: SET LOCAL app.current_user_id = actorId (RLS)
    Service->>DB: SELECT FOR UPDATE WHERE id = documentId
    DB-->>Service: Document verrouille

    Service->>StateMachine: Garde etat = DIP ?
    Service->>DB: UPDATE status = SEALED, returned_at = GREATEST(NOW(), disseminated_at)
    Service->>DB: INSERT outbox audit DOCUMENT_RETURNED (meme tx)
    Service->>DB: COMMIT

    Service->>AuditLog: logAsync(DOCUMENT_RETURNED) (post-commit)
    Service-->>Controller: OK
    Controller-->>Client: 200 OK

2.5.4 Diagramme de sequence — Flux refus securite (audit synchrone)

sequenceDiagram
    participant Client
    participant Guard as JwtAuthGuard / AuthorizationGuard / RateLimitGuard
    participant AuditFilter as C11 DisseminationAuditExceptionFilter
    participant DB as PostgreSQL (vault_secure)

    Client->>Guard: Requete DIP
    Guard--xAuditFilter: Exception (401/403/429/409-RETENTION-DUE)

    AuditFilter->>DB: BEGIN (QueryRunner dedie)
    AuditFilter->>DB: INSERT audit DOCUMENT_DISSEMINATION_DENIED (actor, reason, http_status)
    AuditFilter->>DB: COMMIT
    Note over AuditFilter,DB: Persistance GARANTIE avant reponse HTTP

    AuditFilter-->>Client: HTTP error response (401/403/429/409)

3. Mapping invariants → mécanismes

Invariant ID Exigence Mécanisme Composant Observable Risque
INV-278-01 DIP ∈ enum document_status ALTER TYPE ADD VALUE IF NOT EXISTS 'DIP' + enum TS + tests contractuels vérifiant DIP ∈ enum_range C1, C2, C13 SELECT unnest(enum_range(NULL::document_status)) retourne les valeurs incluant DIP ; l'enum DB contient aussi RESTITUTED (PD-279, 5 valeurs au total) ; TC-INV-01 vérifie DIP ∈ enum_range (pas cardinalité = 4) Migration non idempotente si DIP existe déjà → IF NOT EXISTS
INV-278-02 Gardes SEALED→DIP (copies, retention, auth, role, RLS, rate) Chaîne de gardes séquentielles dans DisseminationService, SELECT FOR UPDATE + vérification champ par champ C9, C8 Tests positifs/négatifs sur chaque garde individuellement Ordre des gardes incorrect → erreur trompeuse ; rate-limit Redis down → fail-closed 503
INV-278-03 DIP→SEALED explicite uniquement Pas de scheduler/cron de retour automatique ; transition uniquement via endpoint API C9, C10 TC-NOM-04 : aucun changement après SLA max Confusion avec pattern SLA scheduler PD-279 (restitution_deadline) → explicitement ABSENT ici
INV-278-04 Audit transitions + refus sécurité Outbox INSERT synchrone (même tx) pour succès ; Exception filter + QueryRunner dédié synchrone pour refus (spec §5.8) C9, C11, C6 Audit DOCUMENT_DISSEMINATED et DOCUMENT_RETURNED dans même tx ; DOCUMENT_DISSEMINATION_DENIED synchrone pour 401/403/429/409-RETENTION-DUE Refus pré-controller (JwtAuthGuard 401) capturé par filter scopé controller (guards dans exception zone NestJS)
INV-278-05 1 attestation par requête SEALED→DIP INSERT attestation dans vault_secure.dissemination_attestations dans même tx, liaison document_ids + actor + motif C1, C2, C9 Requête mono = 1 attestation NULL package_id ; requête multi = 1 attestation avec package_id Échec INSERT attestation → rollback atomique (E-500-ATTESTATION)
INV-278-06 WORM préservé en DIP Aucune mutation contenu/metadata autorisée ; trigger DB BEFORE UPDATE bloque motif_communication C1, C9 TC-NR-01 : tentative mutation échoue ; TC-NOM-06 : UPDATE motif rejeté par trigger Trigger non appliqué si bypass admin → trigger sans exception BYPASSRLS
INV-278-07 RLS préservé en DIP SET LOCAL app.current_user_id dans QueryRunner ; politiques RLS existantes inchangées C9 TC-ERR-05 : refus RLS sur document DIP Rôle PA/SA cross-owner nécessite politique RLS spécifique → à clarifier (H-06)
INV-278-08 EXPIRED terminal strict Machine à états : aucune transition sortante de EXPIRED C3 TC-INV-08 : rejet 409 systématique Déjà implémenté dans PD-250 — non-régression
INV-278-09 Matrice transitions exhaustive Map ALLOWED_TRANSITIONS complète avec toutes les paires autorisées/interdites C3 TC-INV-09 : fuzz toutes combinaisons 5×5 = 25 (incluant RESTITUTED PD-279) Conflit avec PD-250 EXPIRED → DESTROYED (hors périmètre PD-278 mais dans la map)
INV-278-10 Chiffrement repos artefacts crypto Envelope encryption existante (AES-256-GCM/KW) ; attestation hash_evidence et signature_ref via HSM C9 TC-INV-10 : scan DB absence secrets clairs Dépendance HSM PD-37 — stub si non disponible, avec story destination
INV-278-11 Atomicité package (all-or-nothing) SELECT FOR UPDATE sur N documents → gardes par document → si 1 échoue ROLLBACK global C9 TC-ERR-11 : 1 invalide = rejet global ; TC-NOM-02 : N valides = tous DIP Deadlock si ordre verrouillage non respecté → tri lexicographique document_id ASC
INV-278-12 Contrôle concurrence SELECT FOR UPDATE row-level ; conflit → 409 E-409-CONFLICT C9 TC-INV-12 : 2 concurrents, 1 seul réussit Timeout lock configurable pour éviter attente infinie
INV-278-13 Ordre temporel returned_at >= disseminated_at dissemination_returned_at = GREATEST(NOW(), disseminated_at) en SQL C9, C1 TC-NOM-03 : vérification post-transition Horloge NTP désynchronisée → GREATEST() gère le cas
INV-278-14 Anti-contournement rétention Garde retention_due = false sur SEALED→DIP ; retention_service BYPASSRLS pour clôture DIP→SEALED C9, C1 TC-ERR-14 : rejet 409 si retention_due=true ; TC-INV-13 cas B : clôture explicite auditée Fréquence MAJ retention_due (5 min réconciliation) → fenêtre de race

4. Mapping critères d'acceptation → mécanismes

Critère ID Mécanisme(s) Composant Observable Risque
CA-01 ALTER TYPE + enum TS ; l'enum DB contient 5 valeurs (PENDING, SEALED, DIP, EXPIRED, RESTITUTED). PD-278 ajoute DIP. Le test vérifie DIP ∈ enum_range, pas la cardinalité exacte. C1, C2 API expose les états contractuels ; TC-INV-01 vérifie DIP ∈ enum_range Confusion entre enum DB (5 valeurs) et périmètre spec PD-278 (4 valeurs sans RESTITUTED) — documenté V-01
CA-02 6 gardes séquentielles dans DisseminationService + DisseminationRateLimitGuard C8, C9 Tests positifs (toutes gardes OK) + négatifs (chaque garde individuellement) Combinatoire de gardes → tests exhaustifs obligatoires
CA-03 Endpoint DIP→SEALED sans scheduler ; roles {PA, SA, auditor, retention_service} dans AuthorizationGuard C10 Pas de cron ; rejet 403 pour role non autorisé Pas de confusion avec RestitutionSlaScheduler (PD-279)
CA-04 disseminated_at = NOW(), disseminated_by = actorId (serveur uniquement) C9 Timestamps RFC3339 UTC ms dans réponse ; rejet 400 si client fournit
CA-05 GREATEST(NOW(), disseminated_at) dans UPDATE SQL C9 returned_at >= disseminated_at toujours vrai Clock skew → GREATEST gère le cas
CA-06 Outbox INSERT synchrone (succès) + Exception filter synchrone via QueryRunner dédié (refus — spec §5.8) C9, C11 Audit DOCUMENT_DISSEMINATED/RETURNED + DOCUMENT_DISSEMINATION_DENIED persisté avant réponse HTTP Filter scopé controller capture guards (JwtAuthGuard, AuthorizationGuard)
CA-07 INSERT attestation dans vault_secure.dissemination_attestations dans même tx, 1 par requête C1, C2, C9 Mono = 1 attestation ; multi = 1 attestation liée N docs Échec attestation = rollback atomique
CA-08 Validation documents[].length dans DTO (min 1, max N_MAX=100) C5, C10 Rejet 422 pour 0 ou 101 class-validator @ArrayMinSize(1) + @ArrayMaxSize() avec valeur config
CA-09 Aucune route de mutation contenu en DIP ; trigger DB sur motif_communication C1 Tentative UPDATE contenu rejetée ; UPDATE motif rejeté par trigger Trigger DB = dernière ligne de défense
CA-10 Ajout DIP dans DocStates du modèle TLA+ C12 TLC sans violation DocStates ⊆ RealStates Modèle TLA+ complexe, vérification CI requise
CA-11 Tests contractuels couvrant tous les INV-278-* C13 Rapport vert sur TC-INV-01 à TC-INV-13 Volume de tests important (~40+ cas)
CA-12 Persistance motif dans UPDATE + attestation + audit ; trigger WORM C1, C9 DB/audit/attestation cohérents ; UPDATE rejeté
CA-13 SELECT FOR UPDATE ordonné + ROLLBACK si 1 échoue C9 1 invalide → 0 transitions Deadlock possible → tri lexicographique
CA-14 SELECT FOR UPDATE + 409 E-409-CONFLICT C9 2 concurrents → 1 seul réussit Timeout configurable
CA-15 HSM envelope encryption + absence secrets clairs en base C9 Scan DB + vérification chiffrement repos Stub HSM si PD-37 incomplet

5. Mapping tests (TC-*) → mécanismes + observables

5.1 Tests nominaux

Test ID Référence spec Mécanisme(s) Point(s) d'observation Niveau de test visé
TC-NOM-01 INV-02/04/05, CA-02/04/06/07 Gardes OK → UPDATE + attestation + outbox dans tx status=DIP, disseminated_at, attestation_id, audit_log Integration
TC-NOM-02 INV-05/11, CA-07/08/13 Package 1 et 100 docs, SELECT FOR UPDATE ordonné, 1 attestation Tous DIP, package_id non NULL (multi), 1 attestation Integration
TC-NOM-03 INV-03/04/13, CA-03/05/06 Retour explicite, GREATEST(NOW, disseminated_at) status=SEALED, returned_at >= disseminated_at, audit Integration
TC-NOM-04 INV-03, CA-03 Pas de scheduler de retour DIP status reste DIP après durée > SLA max Integration (time-based)
TC-NOM-05 §5.5 SLA, INV-04 Mesure latence horloge monotone, flag SLOW_OPERATION P95 <= cible, flag si dépassement Performance
TC-NOM-06 §5.1, CA-12, INV-06 Motif persisté + présent attestation + audit + trigger WORM Cohérence DB/audit/attestation ; UPDATE motif rejeté Integration
TC-NOM-07 §5.1 package_id Mono: package_id NULL, Multi: package_id UUID v4 serveur package_id conforme, corrélation audit Unit

5.2 Tests d'erreur

Test ID Référence spec Mécanisme(s) Point(s) d'observation Niveau de test visé
TC-ERR-01 E-400-ID-FORMAT ParseUUIDPipe rejet UUID invalide 400, aucun effet Unit
TC-ERR-02 E-400-READONLY-FIELD DTO validation : champs serveur interdits 400, aucun effet Unit
TC-ERR-03 E-401-AUTH, INV-02/04 JwtAuthGuard rejet + Exception filter audit synchrone 401, audit DENIED synchrone Integration
TC-ERR-04 E-403-ROLE, INV-02/04 AuthorizationGuard rejet role + Exception filter audit synchrone 403, audit DENIED synchrone Integration
TC-ERR-05 E-403-RLS, INV-07/04 RLS refuse SELECT → service exception 403, audit DENIED Integration
TC-ERR-06 E-409-STATE, INV-08/09 Machine à états rejette transition 409, motif explicite Unit + Integration
TC-ERR-07 E-422-GUARD-COPIES, INV-02 Garde copies < MIN_COPIES 422, status inchangé Unit
TC-ERR-08A E-422-PACKAGE-SIZE, CA-08 DTO @ArrayMinSize(1) 422, aucun traitement Unit
TC-ERR-08B E-422-PACKAGE-SIZE, CA-08 DTO @ArrayMaxSize(N_MAX) 422, aucun traitement Unit
TC-ERR-09 E-500-ATTESTATION, INV-05 Injection échec attestation → ROLLBACK tx 500, status SEALED Integration
TC-ERR-10 E-500-AUDIT, INV-04 Injection échec outbox → ROLLBACK tx 500, status inchangé Integration
TC-ERR-11 INV-11, CA-13 1 doc invalide dans package → ROLLBACK global Rejet global, 0 transitions Integration
TC-ERR-12 E-403-ROLE, INV-03 Role non autorisé pour DIP→SEALED 403, status DIP, audit DENIED synchrone Unit + Integration
TC-ERR-13 E-429-RATE-LIMIT, INV-02/04 Guard Redis INCR > seuil 429, audit DENIED synchrone Integration
TC-ERR-14 E-409-RETENTION-DUE, INV-02/14 Garde retention_due = true 409, audit DENIED motif retention Integration

5.3 Tests d'invariants

Test ID Référence spec Mécanisme(s) Point(s) d'observation Niveau de test visé
TC-INV-01 INV-01 Requête enum_range PostgreSQL + API DIP ∈ enum_range (pas vérification cardinalité = 4 ; l'enum DB contient aussi RESTITUTED PD-279) Unit
TC-INV-02 INV-02 Batterie combinatoire gardes Acceptation ssi toutes gardes OK Integration
TC-INV-03 INV-03 Pas de timer/cron ; retour explicite seul Pas de changement auto ; retour sur action Integration
TC-INV-04 INV-04 Outbox tx (succès) + exception filter synchrone (refus) Audit complet chaque cas, persisté avant réponse HTTP Integration
TC-INV-05 INV-05 INSERT attestation dans vault_secure.dissemination_attestations dans tx 1 attestation/requête, liée docs+acteur Integration
TC-INV-06 INV-06 Trigger DB + politique applicative Mutation contenu et motif rejetées Integration
TC-INV-07 INV-07 SET LOCAL RLS + politiques PG existantes Refus maintenu DIP Integration
TC-INV-08 INV-08 ALLOWED_TRANSITIONS : EXPIRED → ∅ (niveau applicatif PD-278) Rejet 409 systématique Unit
TC-INV-09 INV-09 Fuzz 5×5 combinaisons (incluant RESTITUTED) Hors matrice → 409 avec motif Unit
TC-INV-10 INV-10 HSM envelope + scan DB Chiffrement actif, aucun secret clair Security
TC-INV-11 INV-11 SELECT FOR UPDATE + ROLLBACK global 1 invalide → 0 effet Integration
TC-INV-12 INV-12 SELECT FOR UPDATE + conflit → 409 1 seul réussit Integration (concurrency)
TC-INV-13 INV-13/14 GREATEST() + retention_service BYPASSRLS Ordre garanti + clôture auditée Integration

5.4 Tests non-régression

Test ID Référence spec Mécanisme(s) Point(s) d'observation Niveau de test visé
TC-NR-01 INV-06 WORM inchangé avant/après DIP Mutation refusée SEALED et DIP Integration
TC-NR-02 INV-07 RLS inchangée Même acteur refuse avant/après Integration
TC-NR-03 Flux PENDING→SEALED Aucune modification ce flux Comportement identique Integration (regression)
TC-NR-04 SEALED→EXPIRED Aucune modification Comportement identique Integration (regression)
TC-NR-05 Enum compatibilité Tous consommateurs acceptent DIP Pas de crash sérialisation Integration
TC-NR-06 Migration down Rollback bloqué si DIP actif Exception explicite Unit

5.5 Tests négatifs/adversariaux

Test ID Référence spec Mécanisme(s) Point(s) d'observation Niveau de test visé
TC-NEG-01 UUID normalisation ParseUUIDPipe accepte uppercase Persistance lowercase Unit
TC-NEG-02 Timestamps serveur DTO rejette timestamps client 400, aucun effet Unit
TC-NEG-03 motif 1025 chars class-validator @MaxLength(1024) 422, aucun effet Unit
TC-NEG-04 Fuzz transitions ALLOWED_TRANSITIONS exhaustive 409, motif explicite Unit
TC-NEG-05 Secret crypto clair Scan base, vérification chiffrement Aucune donnée sensible Security
TC-NEG-06 Rejeu après échec Transaction ROLLBACK + retry Pas d'état partiel ni duplication Integration
TC-NEG-07 Clock skew injecté GREATEST(NOW, disseminated_at) returned_at >= disseminated_at Integration

5.6 Tests formels

Test ID Référence spec Mécanisme(s) Point(s) d'observation Niveau de test visé
TC-FML-01 CA-10, INV-278-01 TLC model checker sur modèle TLA+ avec DIP DocStates ⊆ RealStates sans violation ; toutes transitions DIP vérifiées Formal (TLC)
TC-FML-02 INV-278-01 à 14 Tests contractuels validant chaque INV via assertions Given/When/Then Chaque invariant a au moins 1 test contractuel ; rapport vert Unit + Integration

6. Gestion des erreurs

Code HTTP Code erreur Condition Composant Audit refus
400 E-400-ID-FORMAT UUID invalide dans documents[] C10 (ParseUUIDPipe) Non (erreur client format)
400 E-400-READONLY-FIELD Client fournit dissemination_package_id, disseminated_at ou dissemination_returned_at C5 (DTO validation) Non (erreur client format)
401 E-401-AUTH Token JWT absent/invalide/expiré JwtAuthGuard + C11 Oui (DOCUMENT_DISSEMINATION_DENIED, synchrone)
403 E-403-ROLE Role ∉ {PA, SA, auditor} (F1) ou ∉ {PA, SA, auditor, retention_service} (F2) AuthorizationGuard + C11 Oui (synchrone)
403 E-403-RLS RLS refuse l'accès au(x) document(s) C9 (SET LOCAL + SELECT) + C11 Oui (synchrone)
404 E-404-NOT-FOUND Document(s) non trouvé(s) — SELECT FOR UPDATE retourne moins de lignes que demandé (RLS filtre les documents non autorisés) C9 (SELECT + count check) Non (possiblement RLS → 403 si acteur identifié mais non autorisé ; 404 si document inexistant)
409 E-409-STATE Document pas dans l'état attendu (ex: PENDING→DIP) C9 (garde état) Non (erreur métier, pas sécurité)
409 E-409-CONFLICT Conflit concurrence (SELECT FOR UPDATE collision) C9 (lock timeout) Non (retry possible)
409 E-409-TEMPORAL-ORDER Incohérence temporelle interne (fallback GREATEST échoue) C9 Non (erreur interne rare)
409 E-409-RETENTION-DUE retention_due = true sur document cible C9 (garde retention) + C11 Oui (motif retention_due, synchrone)
422 E-422-GUARD-COPIES geo_copy_count < MIN_COPIES C9 (garde copies) Non (erreur métier)
422 E-422-PACKAGE-SIZE documents[].length = 0 ou > N_MAX C5 (DTO validation) Non (erreur client)
422 E-422-MOTIF-LENGTH motif_communication.length > 1024 C5 (DTO validation) Non (erreur client)
429 E-429-RATE-LIMIT Débit > 60 req/min ou quota > 1000 req/jour C8 (Redis guard) Oui (DOCUMENT_DISSEMINATION_DENIED, synchrone)
500 E-500-ATTESTATION Échec génération attestation C9 (INSERT attestation échoue) → ROLLBACK Non (erreur interne)
500 E-500-AUDIT Échec persistance outbox audit synchrone C9 (INSERT outbox échoue) → ROLLBACK Non (erreur interne)
503 E-503-REDIS Redis indisponible — rate-limit guard fail-closed C8 (Redis connection failure) Non (erreur infrastructure, fail-closed sécuritaire)

Format réponse erreur (pattern PD-279) :

{
  "error_code": "E-409-RETENTION-DUE",
  "message": "Document blocked by retention policy",
  "details": { "document_id": "...", "retention_due": true }
}


7. Impacts sécurité

Risque Mitigation Mécanisme Observable
Exfiltration via maintien DIP prolongé Garde retention_due=false + clôture explicite par retention_service (BYPASSRLS) C9 (INV-278-14) Audit DOCUMENT_RETURNED par retention_service
Brute-force transitions DIP Rate-limit 60/min + quota 1000/jour + audit 429 C8 Compteurs Redis + audit DENIED
Escalade de privilèges cross-owner RLS PostgreSQL préservée (SET LOCAL) + roles IAM stricts C9 TC-ERR-05, TC-INV-07
Secrets crypto en clair Envelope encryption HSM existante ; hash_evidence chiffré au repos C9 TC-INV-10, TC-NEG-05
Contournement WORM via DIP Aucune route de mutation contenu en DIP ; trigger DB sur motif C1 TC-NR-01, TC-INV-06
Race condition rétention retention_due mis à jour par retention orchestrator (événementiel + réconciliation 5 min max) C9 TC-INV-13 cas B
Audit incomplet des refus Exception filter NestJS capture 401/403/429/409-retention via persistance synchrone (QueryRunner dédié, spec §5.8) C11 TC-INV-04 cas refus
Deadlock package multi-documents Tri lexicographique croissant de document_id pour SELECT FOR UPDATE C9 (INV-278-12, §5.9) TC-INV-12
Injection SQL via motif_communication Paramètres positionnels ($1) dans QueryRunner, jamais de concaténation C9 Tests adversariaux
BYPASSRLS retention_service Le rôle retention_service a BYPASSRLS uniquement pour la clôture DIP→SEALED. Ce privilège est audité (DOCUMENT_RETURNED), limité à la transition DIP→SEALED, et ne couvre pas les opérations de lecture. Politique : 1 rôle technique = 1 privilège = 1 audit trail. C9, C1, migration rôle PG TC-INV-13 cas B : clôture auditée, rôle vérifié
Redis fail-closed Si Redis est indisponible, le rate-limit guard retourne 503 (fail-closed). Aucune requête DIP n'est traitée sans rate-limiting actif. C8 TC : Redis mock down → 503

8. Hypothèses techniques

ID Hypothèse Résolution Impact si faux
H-01 Projet cible ProbatioVault-backend (NestJS + TypeORM + PostgreSQL) Confirmé par exploration codebase Architecture à réécrire
H-02 Roles IAM PA, SA, auditor, retention_service existent dans le système auth AuthorizationGuard + @Roles() existant ; vérifier enum roles Sans eux, CA-02/03/14 invalides — créer les roles
H-03 Audit supporte outbox transactionnelle + événements refus Confirmé : AuditLogService.logAsync() + IntegrityJournalService Sinon évolution schéma audit nécessaire
H-04 MIN_COPIES par défaut = 2 Configurable via dissemination.config.ts (Joi) Valeur différente modifie tests limites
H-05 Horodatages métier strictement serveur Pattern existant dans RestitutionService Si client autorisé → contrat à réviser
H-06 copies = geo_copy_count (colonne PD-279 existante) L'entité DocumentSecure possède déjà geo_copy_count DEFAULT 0. La spec §3 définit copies comme "nombre de copies durables sur domaines de panne distincts, fourni par la couche stockage" — sémantiquement identique à geo_copy_count. Validation obligatoire en Phase 0 de l'étape 6. Si distinct → nouvelle colonne + source de données
H-07 retention_due est un champ stocké sur le document, mis à jour par le retention orchestrator La spec §3 le confirme. Le champ n'existe pas encore → migration C1 le crée Si calculé à la volée → performance et atomicité compromises
H-08 retention_service possède un rôle technique avec BYPASSRLS (REX PD-16) Nécessaire pour clôture DIP→SEALED cross-owner. BYPASSRLS contractualisé dans §7 Impacts sécurité. Si absent → créer le rôle dans la migration ou lever une alerte
H-09 Attestation stockée dans table dédiée vault_secure.dissemination_attestations Schéma riche §5.13 (hash_evidence, signature_ref, document_ids array). DDL dans migration C1 (steps 13–17). Entity TypeORM dans C2. Si lifecycle_log → adapter le schéma ou perdre les champs spécifiques

9. Points de vigilance (risques, dette, pièges)

ID Point de vigilance Mitigation
V-01 Enum DB 5 valeurs vs spec 4 : PD-279 a ajouté RESTITUTED. La spec PD-278 liste {PENDING, SEALED, DIP, EXPIRED}. L'enum PostgreSQL contiendra 5 valeurs. Le test CA-01 doit vérifier que DIP est présent, pas que l'ensemble est exactement 4. Test TC-INV-01 : vérifier DIP ∈ enum_range, pas
V-02 Deadlock multi-modules (§5.9) : PD-278 exige que retention_service, export, audit enrichi utilisent le même ordre de verrouillage (tri lexicographique document_id ASC). Vérifier que DocumentPurgeService et RestitutionService sont compatibles. Convention cross-module documentée §11 + revue pré-Gate 8
V-03 ALTER TYPE ADD VALUE hors transaction : PostgreSQL interdit cette DDL dans un bloc BEGIN/COMMIT. La migration TypeORM doit explicitement ne pas utiliser de transaction pour cette commande (pattern PD-279). queryRunner.query() sans transaction wrapping pour cette ligne
V-04 Index partiels sans subquery (REX PD-55) : Les index sur status = 'DIP' ou retention_due = true doivent utiliser des prédicats simples, jamais de subqueries dans le WHERE. WHERE status = 'DIP' — prédicat simple OK
V-05 Exception filter scope : Le DisseminationAuditExceptionFilter doit capturer les rejets des guards en amont du controller. Les guards décorés via @UseGuards() sur le controller sont dans l'exception zone de @UseFilters() du même controller (architecture NestJS). Guards scopés controller → filter controller les capture. Pas besoin d'APP_INTERCEPTOR global.
V-06 BullMQ v5 API : Si un scheduler de monitoring SLA est ajouté, utiliser getJobSchedulers() / removeJobScheduler(), pas les méthodes deprecated (REX PD-55). Pas de scheduler SLA dans PD-278 (pas de timeout implicite) — vigilance si extension future
V-07 crypto.randomUUID() obligatoire (REX PD-63) : Pour dissemination_package_id, attestation_id, request_id. Ne jamais utiliser Math.random(). import { randomUUID } from 'node:crypto'
V-08 Stub copies/geo_copy_count : Si PD-279 n'a pas encore livré le mécanisme complet de comptage des copies, la garde INV-278-02 doit documenter le stub avec story destination. // STUB: PD-279 — geo_copy_count maintained by storage layer
V-09 Fréquence réconciliation retention_due (§5.11 : max 5 min) : Fenêtre de race possible entre mise à jour retention_due et tentative SEALED→DIP. La garde en base (WHERE retention_due = false) est la source de vérité. Garde DB synchrone, pas de cache applicatif pour retention_due
V-10 Down migration bloquée si DIP actif (pattern PD-279) : COUNT(*) WHERE status = 'DIP' > 0 → exception. Ne pas retirer la valeur enum DIP si des documents l'utilisent. Guard dans down() de la migration

10. Hors périmètre

Élément Justification
Refactoring des états existants (PENDING, SEALED, EXPIRED, RESTITUTED) Spec §2 : exclus sauf ajustements strictement nécessaires
Bordereau de destruction (GAP-NF-008) Spec §2 : PD-250
Normalisation format package SEDA/METS Spec §2 : hors périmètre
Interprétation juridique au-delà des règles testables Spec §2 : hors périmètre
Mécanisme de diffusion externe existant Spec §2 : pas de changement
Scheduler de retour automatique DIP→SEALED INV-278-03 : explicitement absent — pas de timeout implicite
Module retention_orchestrator (mise à jour retention_due) L'écriture du champ retention_due est hors périmètre PD-278. PD-278 le lit uniquement. Le stub DOIT documenter la story destination (H-07).
Publication audit externe (async post-commit) Le mécanisme async existant est réutilisé tel quel. Seuls les nouveaux AuditActionType sont ajoutés (C6).

11. Mécanismes cross-module

Élément Détail
Routes d'autres modules à protéger Aucune modification d'autres modules. Les gardes DIP sont internes au nouveau service/controller DisseminationService/Controller. Les endpoints existants (GET /documents/:id/download, GET /documents/:id/export) ne nécessitent pas de guard DIP spécifique — le document reste lisible en DIP (WORM préservé, RLS inchangée).
Machine à états partagée Extension de DocumentStateMachineService (module destruction) avec nouvelles transitions SEALED → DIP et DIP → SEALED. Ce service est déjà importé par documents module.
Types audit partagés Extension de AuditActionType (module audit) avec 3 nouveaux types. Utilisé par C9 et C11.
Types journal partagés Extension de JournalEventType (module integrity) avec 2 nouveaux types. Utilisé par C9.
Convention de verrouillage cross-module (MAJ-05) §5.9 de la spec : tri lexicographique document_id ASC pour tout SELECT FOR UPDATE sur la table documents. Convention partagée, pas d'abstraction DocumentLockService. Justification : les modules existants (RestitutionService, DocumentPurgeService) utilisent déjà ce pattern sans abstraction commune. Introduire un service partagé impliquerait un refactoring cross-module hors périmètre PD-278. Vérification : (1) RestitutionService verrouille 1 doc → pas de conflit d'ordre. (2) DocumentPurgeService verrouille en batch → vérifier tri ASC dans revue pré-Gate 8. Linter recommandé : ajouter un commentaire // LOCK-ORDER: document_id ASC (§5.9) à chaque SELECT FOR UPDATE pour traçabilité. Si un futur module viole la convention → deadlock détecté par tests d'intégration concurrence.

12. Périmètre de test

Framework de test : Jest + ts-jest (transformer TypeScript). Configuration Jest existante dans jest.config.ts du projet backend.

Niveau de test In scope Hors scope (justification)
Unitaire DisseminationService (gardes, logique métier), DTOs (validation), DisseminationConfig (bornes), machine à états (transitions), rate-limit guard, error codes, DisseminationAttestation entity
Intégration Flux complet F1/F2 avec DB réelle (PostgreSQL), atomicité tx, RLS, audit synchrone, concurrence (parallel requests), package multi-documents, trigger WORM motif_communication, table attestations
E2E Hors scope pour PD-278 : pas de test E2E end-to-end complet (client HTTP → DB → audit → attestation) car l'infrastructure E2E complète (avec HSM mock, Redis, PostgreSQL, retention orchestrator) n'est pas encore stabilisée pour les flux DIP. Les tests d'intégration avec @nestjs/testing + DB réelle couvrent le périmètre fonctionnel. Infrastructure E2E avec HSM mock non disponible pour le flux DIP. Tests d'intégration DB couvrent les mêmes invariants.
Performance TC-NOM-05 : mesure SLA P95 sur échantillon statistique (SEALED→DIP, DIP→SEALED) Benchmarks charge multi-nœuds hors scope (infrastructure non représentative en local)
Sécurité TC-INV-10, TC-NEG-05 : scan DB secrets clairs, vérification chiffrement repos Pentest externe hors scope
Formel TC-FML-01 (TLA+ TLC model checker), TC-FML-02 (tests contractuels INV-278-*) Alloy model hors scope PD-278

Couverture minimale attendue : 80% sur le périmètre in scope (C1–C12). Tous les TC-NOM, TC-ERR, TC-INV, TC-NR, TC-NEG et TC-FML répertoriés ci-dessus sont in scope.

Matrice de couverture : TC-NOM-01 à TC-NOM-07, TC-ERR-01 à TC-ERR-14, TC-INV-01 à TC-INV-13, TC-NR-01 à TC-NR-06, TC-NEG-01 à TC-NEG-07, TC-FML-01 à TC-FML-02 = 49 tests contractuels.


13. Détails techniques par composant

C1 — Migration DDL

Fichier : src/database/migrations/1741400000000-PD278-AddDipState.ts

Séquence DDL : 1. ALTER TYPE vault_secure.document_status ADD VALUE IF NOT EXISTS 'DIP'hors transaction (contrainte PostgreSQL) 2. ALTER TABLE vault_secure.documents ADD COLUMN IF NOT EXISTS disseminated_at TIMESTAMPTZ NULL 3. ALTER TABLE vault_secure.documents ADD COLUMN IF NOT EXISTS disseminated_by UUID NULL 4. ALTER TABLE vault_secure.documents ADD COLUMN IF NOT EXISTS dissemination_returned_at TIMESTAMPTZ NULL 5. ALTER TABLE vault_secure.documents ADD COLUMN IF NOT EXISTS dissemination_package_id UUID NULL 6. ALTER TABLE vault_secure.documents ADD COLUMN IF NOT EXISTS motif_communication VARCHAR(1024) NULL 7. ALTER TABLE vault_secure.documents ADD COLUMN IF NOT EXISTS retention_due BOOLEAN NOT NULL DEFAULT false 8. ALTER TABLE ... ADD CONSTRAINT chk_dip_temporal_order CHECK (dissemination_returned_at IS NULL OR disseminated_at IS NULL OR dissemination_returned_at >= disseminated_at) — INV-278-13 9. Trigger WORM motif_communication :

CREATE OR REPLACE FUNCTION vault_secure.trg_motif_communication_immutable()
RETURNS TRIGGER AS $$ BEGIN
  IF OLD.motif_communication IS NOT NULL AND NEW.motif_communication IS DISTINCT FROM OLD.motif_communication THEN
    RAISE EXCEPTION 'motif_communication is immutable after creation';
  END IF;
  RETURN NEW;
END; $$ LANGUAGE plpgsql;
CREATE TRIGGER trg_motif_communication_worm
  BEFORE UPDATE ON vault_secure.documents
  FOR EACH ROW EXECUTE FUNCTION vault_secure.trg_motif_communication_immutable();
10. Index : CREATE INDEX IF NOT EXISTS idx_documents_dip_status ON vault_secure.documents(status) WHERE status = 'DIP' 11. Index : CREATE INDEX IF NOT EXISTS idx_documents_retention_due ON vault_secure.documents(retention_due) WHERE retention_due = true 12. Index : CREATE INDEX IF NOT EXISTS idx_documents_dissemination_package ON vault_secure.documents(dissemination_package_id) WHERE dissemination_package_id IS NOT NULL 13. Table attestations (BLQ-01) :
CREATE TABLE IF NOT EXISTS vault_secure.dissemination_attestations (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  attestation_id UUID NOT NULL UNIQUE,
  request_id UUID NOT NULL,
  document_ids UUID[] NOT NULL,
  actor_id UUID NOT NULL,
  issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  motif_communication VARCHAR(1024),
  dissemination_package_id UUID,
  hash_evidence TEXT NOT NULL,
  signature_ref TEXT,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
14. Index attestations : CREATE INDEX IF NOT EXISTS idx_attestations_actor ON vault_secure.dissemination_attestations(actor_id) 15. Index attestations : CREATE INDEX IF NOT EXISTS idx_attestations_issued_at ON vault_secure.dissemination_attestations(issued_at) 16. Index attestations : CREATE INDEX IF NOT EXISTS idx_attestations_package ON vault_secure.dissemination_attestations(dissemination_package_id) WHERE dissemination_package_id IS NOT NULL 17. Partitioning par année (politique de rétention 10 ans) : La table dissemination_attestations est créée avec partitioning natif PostgreSQL par RANGE(issued_at) pour permettre l'archivage par année. Partition initiale : année courante + année précédente. Les partitions ultérieures sont créées automatiquement via job de maintenance.
-- Si partitioning natif :
CREATE TABLE vault_secure.dissemination_attestations (
  ...
) PARTITION BY RANGE (issued_at);

CREATE TABLE vault_secure.dissemination_attestations_2026
  PARTITION OF vault_secure.dissemination_attestations
  FOR VALUES FROM ('2026-01-01') TO ('2027-01-01');
Alternative si partitioning complexe hors scope : table simple avec index sur issued_at + job d'archivage annuel documenté comme dette technique avec story destination.

Down migration : Vérifier COUNT(*) WHERE status = 'DIP' > 0 → exception si documents DIP actifs. Sinon : DROP table dissemination_attestations, DROP colonnes, DROP trigger, DROP index. Ne pas retirer la valeur enum (PostgreSQL ne supporte pas DROP VALUE).

C2 — Extension entities TypeScript

Fichiers : - src/modules/documents/entities/document-secure.entity.ts — extension enum + colonnes - src/modules/documents/entities/dissemination-attestation.entity.tsnouvelle entity (BLQ-01)

Entity DisseminationAttestation :

@Entity('dissemination_attestations', { schema: 'vault_secure' })
export class DisseminationAttestation {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ type: 'uuid', unique: true })
  attestation_id: string;

  @Column({ type: 'uuid' })
  request_id: string;

  @Column({ type: 'uuid', array: true })
  document_ids: string[];

  @Column({ type: 'uuid' })
  actor_id: string;

  @Column({ type: 'timestamptz', default: () => 'NOW()' })
  issued_at: Date;

  @Column({ type: 'varchar', length: 1024, nullable: true })
  motif_communication: string | null;

  @Column({ type: 'uuid', nullable: true })
  dissemination_package_id: string | null;

  @Column({ type: 'text' })
  hash_evidence: string;

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

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

C4 — Configuration DIP

Fichier : src/modules/documents/config/dissemination.config.ts

registerAs('dissemination', () => ({
  minCopies: parseInt(process.env.DIP_MIN_COPIES ?? '2'),
  maxPackageSize: parseInt(process.env.DIP_MAX_PACKAGE_SIZE ?? '100'),
  slaTargetSealedToDipMs: parseInt(process.env.SLA_TARGET_SEALED_TO_DIP_MS ?? '2000'),
  slaTargetDipToSealedMs: parseInt(process.env.SLA_TARGET_DIP_TO_SEALED_MS ?? '1500'),
  slaHardTimeoutMs: parseInt(process.env.SLA_HARD_TIMEOUT_MS ?? '5000'),
  rateLimitPerMinute: parseInt(process.env.DIP_RATE_LIMIT_PER_MIN ?? '60'),
  quotaPerDay: parseInt(process.env.DIP_QUOTA_PER_DAY ?? '1000'),
}))

Validation Joi avec min/max pour chaque borne (pattern restitution.config.ts).

C8 — Rate-limit guard DIP

Fichier : src/modules/documents/guards/dissemination-rate-limit.guard.ts

Pattern : LegalRateLimitGuard (Redis INCR + EXPIRE).

  • Clé rate-limit : dip:rate:{actorId} — TTL 60s
  • Clé quota journalier : dip:quota:{actorId}:{YYYY-MM-DD} — TTL 86400s
  • Si Redis indisponible → fail-closed 503 E-503-REDIS (pas de bypass)
  • Sur rejet 429 → audit DOCUMENT_DISSEMINATION_DENIED synchrone via QueryRunner dédié dans le filter C11

C9 — Service DIP (core métier)

Fichier : src/modules/documents/services/dissemination.service.ts

Méthode disseminate(dto, actorId) : 1. Validation cardinalité (1..maxPackageSize) 2. queryRunner = dataSource.createQueryRunner() 3. queryRunner.startTransaction() 4. SET LOCAL app.current_user_id = actorId 5. SELECT * FROM vault_secure.documents WHERE id IN ($ids) ORDER BY id ASC FOR UPDATE — verrouillage ordonné ; commentaire // LOCK-ORDER: document_id ASC (§5.9) 6. Vérification : tous trouvés ? (sinon E-404-NOT-FOUND si inexistant, E-403-RLS si RLS filtré) 7. Pour chaque document : - état = SEALED ? (sinon E-409-STATE) - geo_copy_count >= minCopies ? (sinon E-422-GUARD-COPIES) - retention_due = false ? (sinon E-409-RETENTION-DUE) - Si un échoue → ROLLBACK global (INV-278-11) 8. package_id = documents.length > 1 ? randomUUID() : null 9. UPDATE : status=DIP, disseminated_at=NOW(), disseminated_by=actorId, package_id, motif 10. INSERT attestation dans vault_secure.dissemination_attestations (même tx) 11. INSERT outbox audit DOCUMENT_DISSEMINATED (même tx) 12. queryRunner.commitTransaction() 13. AuditLogService.logAsync(DOCUMENT_DISSEMINATED, ...) (post-commit, publication async)

Méthode returnFromDissemination(documentId, actorId) : 1. queryRunner.startTransaction() 2. SET LOCAL app.current_user_id = actorId 3. SELECT ... WHERE id = $id FOR UPDATE ; commentaire // LOCK-ORDER: document_id ASC (§5.9) 4. Vérification trouvé (sinon E-404-NOT-FOUND / E-403-RLS) 5. état = DIP ? (sinon E-409-STATE) 6. UPDATE status=SEALED, dissemination_returned_at = GREATEST(NOW(), disseminated_at) 7. INSERT outbox audit DOCUMENT_RETURNED (même tx) 8. queryRunner.commitTransaction() 9. AuditLogService.logAsync(DOCUMENT_RETURNED, ...) (post-commit)

C11 — Exception filter audit refus (SYNCHRONE — spec §5.8)

Fichier : src/modules/documents/filters/dissemination-audit-exception.filter.ts

Pattern : DepositAuditExceptionFilter (PD-60), adapté pour persistance synchrone (BLQ-02).

@Catch(UnauthorizedException, ForbiddenException, HttpException)
export class DisseminationAuditExceptionFilter implements ExceptionFilter {
  constructor(
    private readonly dataSource: DataSource,
    private readonly auditLogService: AuditLogService,
  ) {}

  async catch(exception: HttpException, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const request = ctx.getRequest();
    const status = exception.getStatus();

    if ([401, 403, 429].includes(status) || 
        (status === 409 && exception.getResponse()?.['error_code'] === 'E-409-RETENTION-DUE')) {
      // Persistance SYNCHRONE via QueryRunner dédié (spec §5.8)
      const queryRunner = this.dataSource.createQueryRunner();
      try {
        await queryRunner.startTransaction();
        await queryRunner.query(
          `INSERT INTO vault_secure.audit_log (action_type, actor_id, metadata, created_at)
           VALUES ($1, $2, $3, NOW())`,
          [
            AuditActionType.DOCUMENT_DISSEMINATION_DENIED,
            request.user?.id ?? null,
            JSON.stringify({
              reason_code: exception.getResponse()?.['error_code'],
              http_status: status,
              document_id: request.params?.id ?? null,
            }),
          ],
        );
        await queryRunner.commitTransaction();
      } catch (auditError) {
        await queryRunner.rollbackTransaction();
        // Log erreur mais ne pas masquer l'exception originale
      } finally {
        await queryRunner.release();
      }
    }

    // Re-throw l'exception originale
    const response = ctx.getResponse();
    response.status(status).json(exception.getResponse());
  }
}

Différence avec v1 : logAsync remplacé par QueryRunner.startTransaction() + INSERT + commitTransaction(). La persistance est garantie AVANT le retour de la réponse HTTP (spec §5.8). La transaction est légère (1 INSERT) et n'impacte pas significativement la latence.


14. Politique de rétention des attestations (MAJ-04)

Élément Détail
Durée de conservation 10 ans minimum (conformité NF Z42-013 §8.3 — traçabilité des communications)
Partitioning Table vault_secure.dissemination_attestations partitionnée par RANGE(issued_at) par année civile
Archivage Partitions > 3 ans : archivage cold storage (S3 Glacier via ProbatioVault-infra). Partitions > 10 ans : purge après validation conformité.
Création partitions Job de maintenance annuel (cron ou migration) crée la partition de l'année N+1
Intégrité Chaque attestation contient hash_evidence (SHA-256 des document_ids + acteur + timestamp) et signature_ref (référence HSM si disponible). Ces champs sont WORM (pas de UPDATE autorisé).
Audit purge Toute suppression de partition d'attestations est auditée (hors scope PD-278, documenté pour le retention orchestrator).

15. Contraintes techniques (MIN-04)

Dépendances inter-PD

PD Dépendance Statut Impact sur PD-278
PD-279 État RESTITUTED, geo_copy_count, RestitutionService DONE Enum DB 5 valeurs (pas 4). geo_copy_count disponible pour garde copies.
PD-250 État EXPIRED, WORM, DocumentStateMachineService, DocumentPurgeService DONE Machine à états étendue. Vérifier ordre verrouillage dans DocumentPurgeService.
PD-37 HSM envelope encryption, signature probante STUB hash_evidence calculé ; signature_ref = stub // STUB: PD-37 — HSM signature probante.
PD-16 Rôle technique retention_service avec BYPASSRLS DONE Utilisé pour clôture DIP→SEALED cross-owner (flux 2.4).
PD-60 Pattern DepositAuditExceptionFilter DONE Pattern réutilisé pour C11, adapté synchrone.
PD-81 Pattern LegalRateLimitGuard (Redis) DONE Pattern réutilisé pour C8.

Dépendances techniques

Dépendance Version Usage
NestJS ^10.x Framework, Guards, Filters, DI
TypeORM ^0.3.x Migrations, Entities, QueryRunner
PostgreSQL ≥ 14 ALTER TYPE ADD VALUE IF NOT EXISTS, partitioning natif
Redis ≥ 6.x Rate-limit guard (INCR + EXPIRE)
Jest + ts-jest ≥ 29.x Tests unitaires + intégration

16. Compatibilité ESM/CJS (MAJ-08)

Élément Configuration
Mode projet CJS (CommonJS) — "module": "commonjs" dans tsconfig.json du backend
Jest config transform: { '^.+\\.tsx?$': 'ts-jest' } dans jest.config.ts
Import node:crypto import { randomUUID } from 'node:crypto' — compatible CJS via ts-jest transformer
Piège ESM Ne PAS utiliser import.meta.url ni top-level await dans les fichiers testés par Jest
TypeORM migrations Les migrations utilisent require-style compatible CJS. Le runner TypeORM charge les migrations via le glob pattern de ormconfig.
ts-jest isolatedModules Si isolatedModules: true dans tsconfig, vérifier que les const enum ne sont pas utilisés (ts-jest ne les résout pas). Utiliser des enum standard.

17. Variables CI requises (MIN-06)

Variable Default Obligatoire Usage
DIP_MIN_COPIES 2 Non Seuil minimum copies durables pour garde SEALED→DIP
DIP_MAX_PACKAGE_SIZE 100 Non Taille max package multi-documents
SLA_TARGET_SEALED_TO_DIP_MS 2000 Non SLA cible P95 transition SEALED→DIP
SLA_TARGET_DIP_TO_SEALED_MS 1500 Non SLA cible P95 transition DIP→SEALED
SLA_HARD_TIMEOUT_MS 5000 Non Timeout dur toute opération DIP
DIP_RATE_LIMIT_PER_MIN 60 Non Rate-limit par acteur par minute
DIP_QUOTA_PER_DAY 1000 Non Quota journalier par acteur
REDIS_HOST / REDIS_PORT Existants Oui Connexion Redis pour rate-limit guard
DATABASE_URL Existant Oui Connexion PostgreSQL

18. Hors périmètre (inchangé)

Voir §10.


19. Mécanismes cross-module (inchangé)

Voir §11.


20. Périmètre de test (inchangé)

Voir §12.


PD-278 — Code Contracts (v2)

# PD-278-code-contracts.yaml
# Story: NF Z42-013 — ajout contractuel de l'état DIP
# Generated: 2026-03-01 (v2 — 19 écarts corrigés)

code_contracts:
  - module: dip-migration
    owner_agent: agent-migration
    interfaces: []
    invariants:
      - "INV-278-01: DIP  enum document_status via ALTER TYPE ADD VALUE IF NOT EXISTS (l'enum DB contient aussi RESTITUTED PD-279)"
      - "INV-278-13: CHECK constraint dissemination_returned_at >= disseminated_at"
      - "INV-278-06: trigger WORM trg_motif_communication_worm bloque UPDATE motif_communication après insertion"
      - "INV-278-05: CREATE TABLE vault_secure.dissemination_attestations avec partitioning par année (issued_at)"
      - "Down migration bloquée si COUNT(*) WHERE status = 'DIP' > 0"
    forbidden:
      - "ALTER TYPE ADD VALUE dans un bloc BEGIN/COMMIT (spec PostgreSQL: DDL enum hors transaction)"
      - "Subquery dans clause WHERE d'un index partiel (REX PD-55: PostgreSQL l'interdit)"
      - "DROP TYPE document_status (PostgreSQL ne supporte pas DROP VALUE  spec DB)"
      - "Math.random() pour tout identifiant (REX PD-63: Sonar S2245 security hotspot)"
    architectural_decisions:
      - decision: "ALTER TYPE hors transaction + colonnes idempotentes IF NOT EXISTS"
        rationale: "Pattern établi PD-250/PD-279, PostgreSQL contraint ALTER TYPE ADD VALUE hors tx"
        alternatives_considered: ["Nouvelle table de mapping status", "Type VARCHAR au lieu d'enum"]
        trade_offs: ["Enum natif = performance + intégrité, mais non supprimable"]
      - decision: "Table dédiée dissemination_attestations avec partitioning RANGE(issued_at)"
        rationale: "Schéma riche §5.13, conservation 10 ans, partitioning = archivage + performance requêtes"
        alternatives_considered: ["Réutilisation integrity_journal_entries", "JSONB dans documents"]
        trade_offs: ["Table dédiée = surface plus large mais requêtes attestation indépendantes et archivage par année"]
    files:
      - "src/database/migrations/1741400000000-PD278-AddDipState.ts"

  - module: dip-attestation
    owner_agent: agent-entity
    interfaces:
      - DisseminationAttestation
    invariants:
      - "INV-278-05: 1 attestation par requête SEALED→DIP, stockée dans vault_secure.dissemination_attestations"
      - "Champs WORM: hash_evidence et signature_ref ne sont jamais modifiés après insertion"
      - "document_ids stocké en UUID[] pour lier N documents à 1 attestation"
      - "Rétention 10 ans, partitioning par année civile sur issued_at"
    forbidden:
      - "UPDATE sur hash_evidence ou signature_ref (WORM  intégrité cryptographique, spec §5.13)"
      - "DELETE sans politique d'archivage validée (conformité NF Z42-013 §8.3)"
      - "Stockage en clair de hash_evidence sans chiffrement au repos (sécurité: envelope encryption)"
    files:
      - "src/modules/documents/entities/dissemination-attestation.entity.ts"

  - module: dip-entity-extension
    owner_agent: agent-entity
    interfaces:
      - DocumentStatus
    invariants:
      - "INV-278-01: DocumentStatus enum contient DIP (DIP  enum, pas vérification de cardinalité exacte)"
      - "Colonnes disseminated_at, disseminated_by, dissemination_returned_at, dissemination_package_id, motif_communication, retention_due déclarées dans l'entity"
    forbidden:
      - "Modifier les colonnes existantes de DocumentSecure (WORM, RLS, status existants  non-régression spec §2)"
      - "Supprimer ou renommer des colonnes PD-279 (restituted_at, restitution_deadline, geo_copy_count  non-régression PD-279 DONE)"
    files:
      - "src/modules/documents/entities/document-secure.entity.ts"

  - module: dip-state-machine
    owner_agent: agent-state-machine
    interfaces:
      - DocumentStateMachineService
    invariants:
      - "INV-278-09: SEALED  DIP et DIP  SEALED sont les seules nouvelles transitions"
      - "INV-278-08: EXPIRED reste terminal strict (aucune transition sortante applicative)"
      - "Transitions PENDING  DIP, DIP  EXPIRED, DIP  PENDING explicitement INTERDITES"
    forbidden:
      - "Modifier les transitions existantes PD-250/PD-279 (non-régression  PD-250 DONE, PD-279 DONE)"
      - "Ajouter une transition sortante de EXPIRED (INV-278-08  spec §4.2 états terminaux)"
      - "Transition implicite timer/cron pour DIP  SEALED (INV-278-03  spec §4.3 retour explicite uniquement)"
    files:
      - "src/modules/destruction/services/document-state-machine.service.ts"

  - module: dip-config
    owner_agent: agent-config
    interfaces:
      - DisseminationConfig
    invariants:
      - "Toutes les bornes numériques sont configurables via env vars avec defaults contractuels"
      - "Validation Joi avec min/max stricts  rejet startup si hors bornes (pas de clamping)"
    forbidden:
      - "Valeurs magiques en dur dans le code hardcoded MIN_COPIES, N_MAX, etc. (maintenabilité: config centralisée)"
      - "Clamping silencieux de valeurs hors bornes (sécurité: fail-fast au démarrage, spec §5.6)"
    files:
      - "src/modules/documents/config/dissemination.config.ts"

  - module: dip-dto
    owner_agent: agent-dto
    interfaces:
      - CreateDisseminationDto
      - DisseminationReturnDto
      - DisseminationResponseDto
      - DisseminationErrorDto
    invariants:
      - "CA-08: documents[] min 1 max N_MAX, rejet 422 hors bornes"
      - "motif_communication max 1024 chars UTF-8"
      - "Champs serveur (dissemination_package_id, disseminated_at, dissemination_returned_at) interdits en entrée"
    forbidden:
      - "Accepter des timestamps client (disseminated_at, returned_at) dans le DTO d'entrée (spec §5.1: horodatage serveur strict, CA-04)"
      - "Accepter dissemination_package_id dans le DTO d'entrée (spec §5.1: généré côté serveur uniquement)"
    files:
      - "src/modules/documents/dto/dissemination*.dto.ts"

  - module: dip-audit-types
    owner_agent: agent-audit
    interfaces:
      - AuditActionType
    invariants:
      - "3 nouveaux types: DOCUMENT_DISSEMINATED, DOCUMENT_RETURNED, DOCUMENT_DISSEMINATION_DENIED"
    forbidden:
      - "Modifier ou supprimer des AuditActionType existants (non-régression  types utilisés par d'autres modules)"
    files:
      - "src/modules/audit/types/audit-action.types.ts"

  - module: dip-journal-types
    owner_agent: agent-journal
    interfaces:
      - JournalEventType
    invariants:
      - "2 nouveaux types: DIP_DISSEMINATED, DIP_RETURNED"
    forbidden:
      - "Modifier ou supprimer des JournalEventType existants (non-régression  types utilisés par IntegrityJournalService)"
    files:
      - "src/modules/integrity/enums/journal-event-type.enum.ts"

  - module: dip-rate-limit
    owner_agent: agent-rate-limit
    interfaces:
      - DisseminationRateLimitGuard
    invariants:
      - "INV-278-02: rate-limit 60 req/min + quota 1000 req/jour par acteur (configurable)"
      - "Fail-closed: si Redis indisponible  503 E-503-REDIS (pas de bypass)"
      - "Rejet 429 déclenche audit DOCUMENT_DISSEMINATION_DENIED via exception filter synchrone C11"
    forbidden:
      - "Enregistrer le guard en APP_GUARD global, scope controller uniquement (sécurité: rate-limit ciblé DIP, pas global)"
      - "Fail-open si Redis down (sécurité: fail-closed obligatoire  INV-278-02, OWASP rate-limiting)"
      - "Rate-limit par document_id au lieu de acteur_id (spec §5.7: quota par acteur, pas par document)"
    architectural_decisions:
      - decision: "Redis INCR + EXPIRE (sliding window) par acteur, pattern LegalRateLimitGuard"
        rationale: "Pattern établi PD-81, performant, fail-closed cohérent"
        alternatives_considered: ["Table PostgreSQL pour quota", "In-memory counter"]
        trade_offs: ["Redis = dépendance externe mais déjà en place"]
    files:
      - "src/modules/documents/guards/dissemination-rate-limit.guard.ts"

  - module: dip-service
    owner_agent: agent-core
    interfaces:
      - DisseminationService
    invariants:
      - "INV-278-02: 6 gardes séquentielles (état, copies, retention, auth/role implicites via guards, RLS via SET LOCAL)"
      - "INV-278-04: outbox audit INSERT synchrone dans la même transaction que UPDATE status"
      - "INV-278-05: 1 attestation par requête, insérée dans vault_secure.dissemination_attestations dans la même transaction"
      - "INV-278-06: aucune mutation contenu en DIP"
      - "INV-278-11: package atomique  1 échec = ROLLBACK global"
      - "INV-278-12: SELECT FOR UPDATE ordonné par document_id ASC (// LOCK-ORDER: document_id ASC §5.9)"
      - "INV-278-13: returned_at = GREATEST(NOW(), disseminated_at)"
      - "INV-278-14: garde retention_due=false sur SEALED→DIP ; retention_service BYPASSRLS pour clôture DIP→SEALED"
    forbidden:
      - "Promise.all sur les vérifications de gardes, séquentiel obligatoire pour diagnostic (spec §5.4: erreur explicite par garde)"
      - "Mutation du contenu documentaire (encrypted_metadata, file_hash, ovh_path) en DIP (INV-278-06: WORM)"
      - "Attestation ou audit hors de la transaction ACID (INV-278-05: atomicité spec §5.13)"
      - "Timeout implicite DIP  SEALED par scheduler/cron (INV-278-03: retour explicite uniquement, spec §4.3)"
      - "Utiliser Math.random() pour package_id, attestation_id (REX PD-63: Sonar S2245, crypto.randomUUID() obligatoire)"
      - "Cache applicatif pour retention_due, lecture directe DB (spec §5.11: source de vérité = DB, fenêtre de race 5 min max)"
    architectural_decisions:
      - decision: "QueryRunner explicite avec SELECT FOR UPDATE ordonné, pattern RestitutionService"
        rationale: "Pattern établi PD-279, atomicité ACID + prévention deadlock par tri lexicographique"
        alternatives_considered: ["DataSource.transaction() wrapping", "Optimistic locking par version"]
        trade_offs: ["QueryRunner = plus verbose mais contrôle total sur verrouillage et RLS"]
      - decision: "Table dédiée vault_secure.dissemination_attestations pour attestations (vs lifecycle_log)"
        rationale: "Schéma riche §5.13 (hash_evidence, signature_ref, document_ids array), conservation 10 ans"
        alternatives_considered: ["Réutilisation integrity_journal_entries", "Colonne JSONB dans documents"]
        trade_offs: ["Table dédiée = plus de surface, mais requêtes attestation indépendantes"]
    files:
      - "src/modules/documents/services/dissemination.service.ts"

  - module: dip-controller
    owner_agent: agent-controller
    interfaces:
      - DisseminationController
    invariants:
      - "POST /api/v1/documents/disseminations pour SEALED  DIP"
      - "POST /api/v1/documents/{id}/dissemination-return pour DIP  SEALED"
      - "Guards: JwtAuthGuard + AuthorizationGuard + DisseminationRateLimitGuard (F1)"
      - "Guards: JwtAuthGuard + AuthorizationGuard (F2, roles incluant retention_service)"
    forbidden:
      - "GET endpoint qui modifie l'état, side effects (REST: safe methods = pas de mutation, spec §5.2)"
      - "Bypass de ParseUUIDPipe sur document_id (sécurité: validation stricte UUID, E-400-ID-FORMAT)"
      - "Enregistrement dans un module autre que DocumentsModule (architecture: cohérence modulaire NestJS)"
    files:
      - "src/modules/documents/controllers/dissemination.controller.ts"

  - module: dip-exception-filter
    owner_agent: agent-filter
    interfaces:
      - DisseminationAuditExceptionFilter
    invariants:
      - "INV-278-04: audit DOCUMENT_DISSEMINATION_DENIED pour 401, 403, 429, 409-RETENTION-DUE"
      - "Persistance SYNCHRONE via QueryRunner dédié (spec §5.8: transaction légère BEGIN/INSERT/COMMIT)"
      - "Persistance garantie AVANT le retour de la réponse HTTP"
      - "Exclure les codes déjà audités par le service (éviter double audit)"
    forbidden:
      - "Swallow d'exceptions sans re-throw (sécurité: l'appelant doit recevoir l'erreur originale)"
      - "logAsync pour les refus sécurité (spec §5.8: persistance synchrone obligatoire via QueryRunner dédié)"
    files:
      - "src/modules/documents/filters/dissemination-audit-exception.filter.ts"

  - module: dip-tla-formal
    owner_agent: agent-formal
    interfaces: []
    invariants:
      - "CA-10: DIP  DocStates dans le modèle TLA+"
      - "TLC vérifie DocStates  RealStates sans violation"
    forbidden:
      - "Modifier _AnchoredFacts.tla directement (généré par CI, modifier norm.yaml  architecture CI/CD)"
      - "Supprimer des états existants du modèle (non-régression: états validés par TLC)"
    files:
      - "ProbatioVault-doc/docs/normes/nf-z42-013/formal/NF_Z42_013.tla"

  - module: dip-tests
    owner_agent: agent-tests
    interfaces: []
    invariants:
      - "Tous TC-INV-01 à TC-INV-13 implémentés en Given/When/Then"
      - "Tests concurrence (TC-INV-12) avec parallel requests réels"
      - "Tests atomicité package (TC-INV-11) avec injection d'échec"
      - "TC-FML-01 (TLA+ TLC) et TC-FML-02 (tests contractuels) inclus"
      - "TC-NOM-07 (package_id mono/multi) inclus"
      - "Framework: Jest + ts-jest"
    forbidden:
      - "Mocks qui masquent le comportement PostgreSQL réel (RLS, triggers, locks)  tests d'intégration = DB réelle obligatoire"
      - "Tests qui dépendent d'un ordre d'exécution non déterministe (fiabilité: tests reproductibles)"
      - "Skip de tests concurrence ou atomicité (INV-278-11/12: invariants critiques, skip = violation constitutionnelle)"
    files:
      - "src/modules/documents/services/dissemination.service.spec.ts"
      - "src/modules/documents/controllers/dissemination.controller.spec.ts"
      - "src/modules/documents/guards/dissemination-rate-limit.guard.spec.ts"
      - "src/modules/documents/filters/dissemination-audit-exception.filter.spec.ts"

Résumé exécutif v2 : 13 composants + 1 entity attestation (dip-attestation), 5 phases (0–4), 14 invariants mappés à des mécanismes techniques concrets, 49 tests contractuels couverts (dont TC-FML-01/02 et TC-NOM-07), 0 transition implicite, atomicité ACID garantie par QueryRunner + SELECT FOR UPDATE ordonné. Audit refus sécurité synchrone (spec §5.8) via QueryRunner dédié. Table vault_secure.dissemination_attestations avec partitioning annuel et rétention 10 ans. Enum DB 5 valeurs (incluant RESTITUTED PD-279) — tests vérifient DIP ∈ enum_range (pas cardinalité = 4). Convention verrouillage cross-module documentée (pas d'abstraction, commentaire // LOCK-ORDER). Codes erreur enrichis : 404 (not found), 503 (Redis fail-closed). BYPASSRLS retention_service contractualisé. Compatibilité ESM/CJS documentée. Variables CI listées. Le plan réutilise les patterns établis PD-279 (RestitutionService), PD-250 (migration enum), PD-81 (rate-limit Redis) et PD-60 (exception filter audit).