PD-79 — Spécification Technique¶
Metadata¶
| Champ | Valeur |
|---|---|
| Story ID | PD-79 |
| Version | 1.0 |
| Date | 2026-02-16 |
| Auteur | ChatGPT (gov-balanced) |
0) Contexte et objectifs¶
Cette spécification étend le module PD-60 sans modifier le moteur probatoire ni créer de nouvel endpoint. L'extension introduit une catégorie documentaire déclarative B2C_EVIDENCE_MINOR avec des règles configurables de formats MIME et de taille maximale.
Principes directeurs: - Réutiliser POST /documents/upload (aucun endpoint additionnel) - Préserver l'atomicité probatoire PD-60 (transaction englobante + audit append-only + JWS) - Conserver le modèle zero-knowledge (pas de fichier en clair persistant côté serveur) - Rendre les règles de catégorie paramétrables (formats, taille max)
1) Modèle de données¶
1.1 Fonctionnalités (F-79-xx)¶
| ID | Fonctionnalité | Description |
|---|---|---|
| F-79-01 | Catégorie optionnelle | Ajouter category dans la requête d'upload, optionnelle, défaut DEFAULT |
| F-79-02 | Catégorie B2C_EVIDENCE_MINOR | Activer une catégorie dédiée avec whitelist MIME stricte |
| F-79-03 | Règles configurables | Formats autorisés et taille max gérés en base (config) |
| F-79-04 | Journalisation renforcée | Ajouter les attributs de catégorie et décision de validation dans l'audit append-only |
| F-79-05 | SLA de scellement | Respect du temps de scellement < 1s pour les uploads valides |
1.2 Extension de l'entité Deposit¶
Ajout de colonnes non disruptives (compatible historique PD-60):
export enum DocumentCategory {
DEFAULT = 'DEFAULT',
B2C_EVIDENCE_MINOR = 'B2C_EVIDENCE_MINOR',
}
@Entity('deposits', { schema: 'vault_secure' })
export class Deposit {
// ...champs existants PD-60
@Column({
name: 'document_category',
type: 'varchar',
length: 40,
default: DocumentCategory.DEFAULT,
})
documentCategory!: DocumentCategory;
@Column({
name: 'detected_mime_type',
type: 'varchar',
length: 120,
nullable: true,
})
detectedMimeType?: string;
@Column({
name: 'uploaded_size_bytes',
type: 'bigint',
nullable: true,
})
uploadedSizeBytes?: string;
}
1.3 Table de configuration des catégories¶
Objectif: externaliser les règles de validation par catégorie pour éviter le hard-code.
@Entity('document_category_configs', { schema: 'vault_secure' })
export class DocumentCategoryConfig {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'category', type: 'varchar', length: 40, unique: true })
category!: string;
@Column({ name: 'allowed_mime_types', type: 'jsonb' })
allowedMimeTypes!: string[];
@Column({ name: 'max_size_bytes', type: 'bigint' })
maxSizeBytes!: string;
@Column({ name: 'sealing_sla_ms', type: 'integer', default: 1000 })
sealingSlaMs!: number;
@Column({ name: 'audit_level', type: 'varchar', length: 20, default: 'STANDARD' })
auditLevel!: 'STANDARD' | 'REINFORCED';
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
}
1.4 Données de configuration initiales (seed)¶
| category | allowed_mime_types | max_size_bytes | sealing_sla_ms | audit_level |
|---|---|---|---|---|
| DEFAULT | (règles PD-60 actuelles) | (valeur PD-60) | 1000 | STANDARD |
| B2C_EVIDENCE_MINOR | image/png, image/jpeg, audio/mp4, audio/mpeg, audio/wav, video/mp4, video/quicktime | 104857600 | 1000 | REINFORCED |
2) API Contract¶
2.1 Endpoint (inchangé)¶
POST /documents/upload- Pas de nouvel endpoint
- Même moteur de scellement PD-60
2.2 DTO étendu¶
export const PROBATIVE_NOTICE_TEXT = 'Vous réalisez un acte probatoire daté.';
export enum DocumentCategory {
DEFAULT = 'DEFAULT',
B2C_EVIDENCE_MINOR = 'B2C_EVIDENCE_MINOR',
}
export class CreateDepositDto {
@IsString()
@IsNotEmpty()
@MaxLength(255)
clientRequestId!: string;
@IsString()
@IsNotEmpty()
@Matches(/^[a-f0-9]{64}$/)
documentFingerprint!: string;
@IsBoolean()
@Equals(true)
probativeNoticeAck!: boolean;
@IsString()
@Equals(PROBATIVE_NOTICE_TEXT)
probativeNoticeText!: string;
@IsOptional()
@IsEnum(DocumentCategory)
category?: DocumentCategory;
}
Règle d'interprétation: - si category absent ou null -> DEFAULT - si category fourni -> doit appartenir à l'enum sinon 400
2.3 Contrat de réponse (ajout non bloquant)¶
Le schéma de réponse peut inclure, sans rupture, les métadonnées suivantes:
type UploadResponse = {
depositId: string;
proofReceipt: string;
existenceTimestampUtc: string;
category: 'DEFAULT' | 'B2C_EVIDENCE_MINOR';
};
3) Logique métier¶
3.1 CategoryConfigService¶
Responsabilités: - Charger la configuration active d'une catégorie - Résoudre DEFAULT si catégorie absente - Vérifier whitelist MIME - Vérifier taille max - Exposer un cache local court (TTL) pour minimiser latence sans compromettre la cohérence
export interface ResolvedCategoryConfig {
category: 'DEFAULT' | 'B2C_EVIDENCE_MINOR';
allowedMimeTypes: string[];
maxSizeBytes: number;
sealingSlaMs: number;
auditLevel: 'STANDARD' | 'REINFORCED';
}
export interface CategoryConfigService {
resolve(category?: string): Promise<ResolvedCategoryConfig>;
assertMimeAllowed(config: ResolvedCategoryConfig, mime: string): void;
assertSizeAllowed(config: ResolvedCategoryConfig, bytes: number): void;
}
3.2 Enchaînement de validation dans DepositService¶
Ordre de traitement (avant scellement final): 1. Parser category (défaut DEFAULT) 2. Charger config catégorie 3. Détecter MIME réel (sniffing serveur, sans confiance au header client) 4. Contrôler MIME contre whitelist catégorie 5. Contrôler taille réelle contre max_size_bytes 6. Valider fingerprint SHA-256 (server-side constant-time compare) 7. Exécuter transaction probatoire PD-60 (S3 opaque + DB + audit + JWS)
Apprentissages réutilisés: - PD-43: traitement streaming (pas de buffering complet mémoire) - PD-38: hash probatoire validé côté serveur, comparaison constant-time - PD-60: transaction englobante, idempotence advisory lock, audit append-only
3.3 Journalisation renforcée (audit append-only)¶
Pour B2C_EVIDENCE_MINOR, enrichir l'événement d'audit avec: - documentCategory - detectedMimeType - uploadedSizeBytes - categoryConfigVersion (ou hash config) - validationDecision (ACCEPTED | REJECTED + motif)
Exigence: écriture append-only uniquement, aucune mise à jour destructrice.
4) Flux nominal (diagramme de séquence textuel)¶
Client -> API POST /documents/upload (multipart + metadata + category?)
API -> DTO Validation (class-validator)
API -> CategoryConfigService.resolve(category || DEFAULT)
API -> Stream Processor: detect MIME + count size + compute SHA-256
API -> CategoryConfigService.assertMimeAllowed
API -> CategoryConfigService.assertSizeAllowed
API -> DepositService.validateFingerprintConstantTime
API -> Transaction (advisory lock by clientRequestId)
Transaction -> S3 opaque write (no clear-file persistence)
Transaction -> Insert vault_secure.deposits (with document_category)
Transaction -> Append audit trace (reinforced fields)
Transaction -> Generate JWS proof receipt
Transaction -> Commit
API -> 201 Created (depositId, proofReceipt, existenceTimestampUtc, category)
4bis) Diagrammes Mermaid¶
4bis.1 Diagramme d'états — Cycle de vie d'un dépôt¶
Le dépôt traverse plusieurs états de validation avant scellement. Tout rejet est final et audité (INV-79-04). Aucun upload non scellé ne persiste (INV-79-02).
stateDiagram-v2
[*] --> RECEIVED : POST /documents/upload
RECEIVED --> DTO_VALIDATED : class-validator OK
RECEIVED --> REJECTED_400 : category invalide (ERR-79-001)
DTO_VALIDATED --> CONFIG_RESOLVED : CategoryConfigService.resolve()
DTO_VALIDATED --> REJECTED_422_CONFIG : config absente/inactive (ERR-79-002)
CONFIG_RESOLVED --> STREAM_ANALYZED : MIME détecté + taille comptée + SHA-256
STREAM_ANALYZED --> MIME_VALIDATED : MIME ∈ whitelist catégorie
STREAM_ANALYZED --> REJECTED_415 : MIME hors whitelist (ERR-79-003)
MIME_VALIDATED --> SIZE_VALIDATED : taille ≤ max_size_bytes
MIME_VALIDATED --> REJECTED_413 : taille > max_size_bytes (ERR-79-004)
SIZE_VALIDATED --> FINGERPRINT_VALIDATED : SHA-256 constant-time match
SIZE_VALIDATED --> REJECTED_422_FP : fingerprint mismatch (ERR-79-005)
FINGERPRINT_VALIDATED --> SEALED : transaction probatoire PD-60 commit
FINGERPRINT_VALIDATED --> REJECTED_500 : erreur interne (ERR-79-007)
SEALED --> [*] : 201 Created + proofReceipt
REJECTED_400 --> [*] : 400 Bad Request
REJECTED_422_CONFIG --> [*] : 422 Unprocessable Entity
REJECTED_415 --> [*] : 415 Unsupported Media Type
REJECTED_413 --> [*] : 413 Payload Too Large
REJECTED_422_FP --> [*] : 422 Unprocessable Entity
REJECTED_500 --> [*] : 500 Internal Server Error
note right of SEALED
INV-79-01 : aucun fichier clair persistant
INV-79-03 : dépôt immuable (insert-only)
INV-79-05 : preuve JWS traçable
INV-79-06 : scellement < 1s
end note
note right of REJECTED_415
INV-79-02 : aucune persistance
INV-79-04 : audit append-only du rejet
end note 4bis.2 Diagramme de séquence — Upload avec catégorie B2C_EVIDENCE_MINOR¶
Interactions multi-services pour un upload catégorisé, incluant la détection MIME serveur, la validation configurable et la transaction probatoire PD-60.
sequenceDiagram
participant C as Client
participant API as DepositController
participant DTO as class-validator
participant CCS as CategoryConfigService
participant SP as StreamProcessor
participant DS as DepositService
participant S3 as S3 Opaque Storage
participant DB as PostgreSQL (vault_secure)
participant AUD as AuditLog (append-only)
participant JWS as JWS Signer
C->>API: POST /documents/upload<br/>(multipart + category=B2C_EVIDENCE_MINOR)
API->>DTO: validate(CreateDepositDto)
DTO-->>API: ✓ DTO valide
API->>CCS: resolve("B2C_EVIDENCE_MINOR")
CCS->>DB: SELECT FROM document_category_configs<br/>WHERE category = 'B2C_EVIDENCE_MINOR' AND is_active
DB-->>CCS: ResolvedCategoryConfig (cache TTL)
CCS-->>API: config {allowedMimeTypes, maxSizeBytes, auditLevel: REINFORCED}
API->>SP: stream(file) → detect MIME + count size + compute SHA-256
SP-->>API: {detectedMime, sizeBytes, sha256Hash}
API->>CCS: assertMimeAllowed(config, detectedMime)
Note over CCS: INV-79-02 : rejet si MIME ∉ whitelist
API->>CCS: assertSizeAllowed(config, sizeBytes)
Note over CCS: INV-79-02 : rejet si taille > 100 Mo
API->>DS: validateFingerprintConstantTime(sha256Hash, documentFingerprint)
Note over DS: INV-79-05 : comparaison constant-time (PD-38)
rect rgb(230, 245, 230)
Note over API,JWS: Transaction probatoire PD-60 (advisory lock by clientRequestId)
API->>S3: PUT objet opaque (stream chiffré)
Note over S3: INV-79-01 : aucun fichier clair persistant
API->>DB: INSERT vault_secure.deposits<br/>(document_category, detected_mime_type, uploaded_size_bytes)
Note over DB: INV-79-03 : insert-only, pas d'UPDATE/DELETE
API->>AUD: append(DEPOSIT_ACCEPTED,<br/>{category, detectedMime, sizeBytes, configVersion, decision: ACCEPTED})
Note over AUD: INV-79-04 : audit renforcé (auditLevel=REINFORCED)
API->>JWS: sign(depositId, fingerprint, timestampUTC)
JWS-->>API: proofReceipt (JWS compact)
Note over JWS: INV-79-06 : scellement < 1s (SLA 1000ms)
API->>DB: COMMIT
end
API-->>C: 201 Created {depositId, proofReceipt, existenceTimestampUtc, category} 5) Flux alternatifs¶
5.1 Catégorie invalide¶
- Condition:
categorynon présente dans enum - Résultat:
400 Bad Request - Aucune persistance fichier ni dépôt
5.2 Catégorie inconnue en configuration¶
- Condition: enum valide mais config absente/inactive
- Résultat:
422 Unprocessable Entity - Audit de rejet append-only avec motif
CATEGORY_CONFIG_MISSING
5.3 Format non autorisé¶
- Condition: MIME détecté hors whitelist de la catégorie
- Résultat:
415 Unsupported Media Type - Arrêt avant transaction de scellement
- Aucune persistance d'upload
5.4 Taille dépassée¶
- Condition: taille stream >
max_size_bytes - Résultat:
413 Payload Too Large - Stream interrompu immédiatement
- Aucune persistance d'upload
5.5 Fingerprint invalide¶
- Condition: hash recalculé !=
documentFingerprint - Résultat:
422 Unprocessable Entity - Aucune persistance d'upload
5.6 Rejeu idempotent¶
- Condition: même
clientRequestIddéjà scellé - Résultat: réponse idempotente conforme PD-60 (même preuve/identifiant logique)
6) Invariants techniques (mapping INV-79-xx)¶
| Invariant | Exigence | Implémentation technique | Vérification |
|---|---|---|---|
| INV-79-01 | Aucun fichier en clair côté serveur | Stream direct vers stockage opaque, chiffrement/opaque path, pas de disque local persistant | Test d'intégration + inspection runtime |
| INV-79-02 | Aucun upload non scellé ne persiste | Validation complète avant commit final, rollback transactionnel en erreur | Tests de rollback sur erreurs 4xx/5xx |
| INV-79-03 | Aucune modification ultérieure | Dépôt immuable (insert-only), absence d'API update/delete dépôt | Tests contractuels API + contrôle schéma |
| INV-79-04 | Aucun effacement silencieux | Audit append-only systématique des rejets et succès | Tests audit + requêtes DB append-only |
| INV-79-05 | Preuve traçable indépendamment de la plateforme | proofReceipt JWS + documentFingerprint + timestamp UTC | Vérification JWS + test de relecture externe |
| INV-79-06 | Scellement < 1 seconde | SLA configuré (sealing_sla_ms=1000), métriques de latence p95/p99 | Tests perf et monitoring en production |
7) Critères d'acceptation (CA-79-xx)¶
| ID | Critère SMART |
|---|---|
| CA-79-01 | Spécifique: Quand category est absent, l'API traite la demande avec DEFAULT. Mesurable: 100% des tests d'intégration "category absent" retournent 201 sur payload valide. Temporel: validé en CI à chaque merge. |
| CA-79-02 | Spécifique: category=B2C_EVIDENCE_MINOR accepte uniquement les 7 MIME autorisés. Mesurable: 7 tests passants + au moins 1 test rejet par famille MIME. Temporel: exécution en pipeline de tests. |
| CA-79-03 | Spécifique: Tout MIME hors whitelist B2C retourne 415 avec code métier dédié. Mesurable: taux de conformité 100% sur matrice de tests négatifs. Temporel: vérifié sur suite d'intégration. |
| CA-79-04 | Spécifique: Taille > 100 Mo pour B2C retourne 413 et interrompt le stream. Mesurable: aucun objet persistant observé après test (DB+stockage). Temporel: vérifié à chaque release candidate. |
| CA-79-05 | Spécifique: Hash serveur SHA-256 en comparaison constant-time reste obligatoire pour toute catégorie. Mesurable: tests existants PD-60 non régressés (100% pass). Temporel: CI continue. |
| CA-79-06 | Spécifique: L'audit append-only contient documentCategory, detectedMimeType, uploadedSizeBytes, validationDecision pour B2C. Mesurable: 100% des événements B2C contrôlés en test ont ces champs. Temporel: validé en environnement de test avant mise en prod. |
| CA-79-07 | Spécifique: Scellement B2C respecte < 1s. Mesurable: p95 <= 1000 ms sur N>=1000 uploads de référence (<=100 Mo). Temporel: campagne de perf pré-release. |
| CA-79-08 | Spécifique: Idempotence par clientRequestId inchangée. Mesurable: 100% des tests de rejeu renvoient un résultat cohérent sans doublon DB. Temporel: exécuté en CI. |
8) Codes d'erreur¶
| ID | HTTP | Condition | Message API (exemple) |
|---|---|---|---|
| ERR-79-001 | 400 | Champ category invalide (enum) | Invalid category value |
| ERR-79-002 | 422 | Catégorie valide mais non configurée/inactive | Category configuration unavailable |
| ERR-79-003 | 415 | MIME détecté non autorisé pour la catégorie | Unsupported media type for category |
| ERR-79-004 | 413 | Taille max catégorie dépassée | File too large for category |
| ERR-79-005 | 422 | Fingerprint SHA-256 invalide | Document fingerprint mismatch |
| ERR-79-006 | 409 | Conflit idempotence non récupérable (cas limite lock) | Idempotency conflict |
| ERR-79-007 | 500 | Erreur interne pendant transaction probatoire | Probative deposit processing failed |
9) Contraintes de non-régression et implémentation¶
- Ne pas créer de nouvel endpoint.
- Ne pas modifier la logique de scellement probatoire PD-60 (transaction unique, audit append-only, JWS).
- Introduire la catégorie comme extension déclarative et configurable.
- Préserver les performances via traitement streaming et cache de configuration court TTL.
10) Plan de tests minimal associé à la spécification¶
- Tests unitaires
CategoryConfigService: - résolution défaut
DEFAULT - résolution
B2C_EVIDENCE_MINOR - rejet config absente/inactive
- validation MIME/taille
- Tests intégration upload:
- 7 MIME autorisés B2C ->
201 - MIME non autorisé ->
415 -
100 Mo ->
413 - hash invalide ->
422 - category absent -> comportement
DEFAULT - idempotence
clientRequestId - Tests audit:
- présence des champs renforcés en succès/rejet B2C
- append-only strict
- Tests performance:
- p95 scellement <= 1s pour scénario de charge de référence
11) Décisions techniques explicites¶
categoryest optionnel pour compatibilité ascendante des clients PD-60.- La whitelist est validée sur MIME détecté côté serveur, pas sur extension du fichier.
- Les limites de taille et MIME sont pilotées par configuration en base pour permettre des évolutions sans refonte API.
- Le régime probatoire reste strictement identique à PD-60, seule la politique d'admission du fichier varie selon la catégorie.