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();
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()
);
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');
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.ts — nouvelle 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_DENIEDsynchrone 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).