PD-286 / C9 — Livrable agent app-pvproof-assembler-multi
Agent : agent-developer (Claude) Module : app-pvproof-assembler-multi Projet : ProbatioVault-app (React Native + Expo SDK 54 + TypeScript) Référence plan : PD-286-plan.md §1.2 C9, §3 (INV-286-08), §5 (TC-NOM-03 / TC-NR-02) Référence contract : PD-286-code-contracts.yaml module app-pvproof-assembler-multi
1. Synthèse
Extension de l'assembler .pvproof (PD-85) avec le shape multi-volumes (PD-286) — strictement additif :
- conteneur
.pvproof inchangé (forbidden : modification du format) — la classe PvproofAssembler n'a pas changé d'API publique ; pvproof.json interne reçoit deux clés additionnelles uniquement sur un export multi-volumes : volumes_count et assembled_from[] ; - sur un export single-volume legacy (INV-286-05),
pvproof.json reste byte-identical à PD-85 (clés volumes_count / assembled_from absentes, pas null) ; assembled_from[] est strictement projeté sur {volumeIndex, integrityHash, estimatedBytes} — signedUrl (donnée temporaire) et manifest (taille excessive) sont absents ; - assertion runtime fail-closed à la sérialisation :
assembled_from.length === volumes_count (INV-286-08) + contiguïté + ordre ascendant (INV-286-06).
L'orchestrator (C7) consommera l'API buildPvproofMetadata(...) puis assembler.addPvproofMetadata(metadata) — le pipeline d'assemblage du conteneur (entrées ZIP, finalize, hash) reste identique à PD-85.
2. Fichiers livrés
| Fichier | Lignes | Rôle |
src/export/pvproof-assembler.ts | 369 | Extension PD-286 : types AssembledVolumeInput, PvproofAssemblyMetadata{Legacy,Multi}, PvproofMetadataInput, type guard isMultiVolumeMetadata, error class PvproofMultiVolumeMetadataError, builder pur buildPvproofMetadata, assertion runtime assertMultiVolumeMetadataConsistent, méthode PvproofAssembler.addPvproofMetadata |
src/export/__tests__/pvproof-assembler.test.ts | 460 | 36 tests (4 PD-85 préservés + 32 PD-286), couvrent TC-NOM-03 + invariants forbidden + assertions fail-closed |
Aucun fichier hors périmètre n'a été modifié. Périmètre contract :
src/export/pvproof-assembler.ts
src/export/__tests__/pvproof-assembler.test.ts
3.1 Interfaces (contract interfaces)
| Contract | Implémenté | Localisation |
PvproofAssembler (extension) | Méthode ajoutée addPvproofMetadata(metadata: PvproofAssemblyMetadata) ; classe inchangée par ailleurs (forbidden : modification format conteneur) | pvproof-assembler.ts méthode addPvproofMetadata |
PvproofAssemblyMetadata | Type union Legacy \| Multi exporté + helpers (type guard, builder, assertion, error class) | pvproof-assembler.ts (section PD-286 — Types contractuels) |
3.2 Invariants (contract invariants)
| Invariant contract | Mécanisme | Test |
INV-286-08 — .pvproof final UNIQUE, conteneur format inchangé | La classe ne génère qu'un fichier sur finalize() ; toute re-finalisation throw Assembler already finalized (PD-85). API publique addPvproofMetadata délègue à addPvproofJson (PD-85) — aucun changement de format | produces a single .pvproof file on finalize (no duplicate output) |
INV-286-08 — pvproof.json contient volumes_count = totalVolumes ET assembled_from[] de cardinalité = totalVolumes (assertion runtime à la finalisation) | buildPvproofMetadata valide à la construction ; assertMultiVolumeMetadataConsistent ré-asserte au moment de la sérialisation via addPvproofMetadata (dernier rempart, fail-closed) | emits volumes_count and assembled_from with cardinality === totalVolumes + throws when assembled_from.length !== volumes_count |
assembled_from[] projeté sur {volumeIndex, integrityHash, estimatedBytes} — sans signedUrl, sans manifest | Le .map(v => ({volumeIndex, integrityHash, estimatedBytes})) dans buildPvproofMetadata ne propage que les 3 clés autorisées | strips signedUrl and manifest from assembled_from entries (forbidden) |
Single-volume legacy : pvproof.json INCHANGÉ par rapport à PD-85 (pas de volumes_count, pas de assembled_from) — INV-286-05 | Branche if (!hasVolumes) retourne le shape Legacy strict ; assertion explicite Object.keys(meta) not.toContain('volumes_count') | returns legacy shape when volumes is omitted + returns legacy shape when volumes is an empty array |
Concaténation déterministe : ordre par volumeIndex ascendant | [...volumes].sort((a, b) => a.volumeIndex - b.volumeIndex) avant projection ; assertion runtime assembled_from[i].volumeIndex === i | sorts assembled_from by volumeIndex ascending (deterministic) + throws when assembled_from is not sorted ascending |
Réutilise streaming-hasher.ts (PD-85) pour le hash final | Inchangé — l'orchestrator (C7) appelle hashFileStreaming(outputPath) après assembler.finalize() (PD-85). C9 ne touche pas à cette chaîne | — (responsabilité orchestrator inchangée) |
3.3 Forbidden (contract forbidden)
| Interdiction | Vérification |
Inclusion de signedUrl dans assembled_from[] | buildPvproofMetadata projette explicitement sur 3 clés ; testé par strips signedUrl and manifest from assembled_from entries (entrée bruitée avec signedUrl injecté → absent en sortie) |
Inclusion du manifest partiel complet dans assembled_from[] | Idem ; même test couvre la clé manifest |
Production de plus d'un fichier .pvproof — UNIQUE strict | mockWriteAsStringAsync.toHaveBeenCalledTimes(1) après finalize() ; deuxième finalize() throw already finalized (test produces a single .pvproof file on finalize) |
Modification du format conteneur (.pvproof) hors PD-85 | Classe PvproofAssembler non modifiée — addPvproofMetadata est une nouvelle méthode qui délègue à addPvproofJson (PD-85) sans changer la séquence des entrées ZIP. Aucun changement à addFile, addFileStored, addFileFromDisk, finalize, addPvproofJson |
Concaténation dans un ordre autre que volumeIndex ascendant | Sort effectué dans le builder ; assertion runtime sur ordre strictement ascendant dans assertMultiVolumeMetadataConsistent (testé par throws when assembled_from is not sorted ascending) |
Cardinalité assembled_from[] ≠ totalVolumes — assertion fail-closed | Vérifiée 2× : (a) au build, (b) au runtime juste avant sérialisation (test throws when assembled_from.length !== volumes_count) |
3.4 Fichiers (contract files)
| Contract | Livré |
src/export/pvproof-assembler.ts | OK (extension uniquement) |
src/export/__tests__/pvproof-assembler.test.ts | OK (36 tests, dont 4 préservés de PD-85) |
4. Couverture des critères et tests du plan
4.1 Critères d'acceptation couverts
| ID | Critère | Mécanisme dans C9 |
| CA-286-04 | Assemblage app produit un unique .pvproof (INV-286-08) | PvproofAssembler.finalize() produit un seul fichier (PD-85), addPvproofMetadata délègue ; testé par mock writeAsStringAsync appelé exactement 1×. La 2ᵉ finalize throw |
| CA-286-03 | pvproof.json interne enrichi volumes_count + assembled_from[] cardinalité = totalVolumes | buildPvproofMetadata produit le shape multi ; assertMultiVolumeMetadataConsistent re-vérifie à la sérialisation |
4.2 Tests couverts
| Test ID plan | Implémenté ici | Cas |
| TC-NOM-03 | OK | emits volumes_count and assembled_from with cardinality === totalVolumes (3 volumes, ordre [0,1,2] vérifié) ; defaults totalVolumes to volumes.length when omitted (2 volumes auto-déduits) ; accepts multi-volume metadata produced by the builder (intégration class + builder) ; produces a single .pvproof file on finalize (.pvproof unique strict) |
| TC-NR-01 (régression PD-85) | OK | Tests filename PD-85 préservés inchangés |
| TC-NR-02 (régression conteneur PD-85) | OK partiel | Vérifié via tests sibling pvproof-assembler-class.test.ts (8/8 passent) — la classe PvproofAssembler n'a pas changé de comportement public sur les méthodes existantes ; vérification consommateur PD-85 e2e relève de C11 (suite tests) |
4.3 Tests additionnels (qualité)
- Single-volume legacy (3 cas) —
returns legacy shape when volumes is omitted, ... when volumes is an empty array, derives exportDate from clock when omitted. Protègent INV-286-05 contre régression future qui ajouterait volumes_count: null. - Sort déterministe (1 cas) —
sorts assembled_from by volumeIndex ascending avec input shuffle [2, 0, 1]. Garantit le déterminisme inter-runtime. - Forbidden fields (1 cas) —
strips signedUrl and manifest from assembled_from entries injecte des clés interdites en entrée et vérifie leur absence en sortie (assertion sur Object.keys). - Validations strictes (10 cas fail-closed) — cardinalité, hors-plage, négatif, doublon, regex hash (lowercase strict, longueur, charset), bornes
estimatedBytes (zéro, non-entier). - Assertion runtime (4 cas) —
assertMultiVolumeMetadataConsistent accepté ; rejette cardinalité ≠ volumes_count, ordre non ascendant, gap dans la séquence. - Type guard (2 cas) —
isMultiVolumeMetadata retourne true / false selon shape. - Intégration class (4 cas) —
addPvproofMetadata accepte legacy, accepte multi cohérent, throw sur multi incohérent, produit un seul fichier en sortie. - Total — 36/36 tests passent (4 PD-85 préservés + 32 PD-286).
5. Décisions architecturales
Format architectural_decisions du code contract — max 1-2 décisions, focalisées sur des choix non triviaux.
architectural_decisions:
- decision: |
Externaliser la construction de la métadonnée dans une fonction pure
`buildPvproofMetadata(input)` plutôt qu'un constructeur de la classe.
rationale: |
Le shape multi-volumes nécessite 5 validations indépendantes (cardinalité,
hors-plage, doublon, regex hash, bornes estimatedBytes) et un tri
déterministe. Mettre cette logique dans le constructeur de
`PvproofAssembler` aurait fait grossir la classe (déjà 100 lignes pour
le streaming ZIP) et empêché les tests unitaires sans instancier le
pipeline ZIP. La fonction pure rend le code 100% testable sans mock
`fflate` et permet à l'orchestrator (C7) d'appeler le builder une fois
au démarrage de la phase ASSEMBLING, séparant clairement validation
logique et écriture conteneur.
alternatives_considered:
- "Constructeur enrichi `new PvproofAssembler({ volumes, totalVolumes })` — rejeté : couple validation à l'instanciation, casse la classe PD-85"
- "Méthode statique `PvproofAssembler.buildMetadata(...)` — rejeté : n'apporte pas de bénéfice par rapport à une fonction libre, ajoute du couplage"
trade_offs:
- "+ Validation testable indépendamment du pipeline ZIP (mock `fflate` non requis)"
- "+ L'orchestrator peut valider la métadonnée AVANT d'instancier l'assembler"
- "+ Class `PvproofAssembler` PD-85 préservée à l'identique"
- "- 1 fonction de plus dans le namespace exporté (mineur)"
- decision: |
Double validation : `buildPvproofMetadata` valide à la construction et
`assertMultiVolumeMetadataConsistent` ré-asserte à la sérialisation
via `addPvproofMetadata`.
rationale: |
Le contract exige une « assertion runtime à la finalisation » (INV-286-08).
Une métadonnée pourrait être produite hors `buildPvproofMetadata` (test
qui forge un objet manuellement, refactor futur, sérialisation/désérialisation
qui passe par JSON). L'assertion à la sérialisation garantit le fail-closed
même si le builder est by-passé. Coût : O(N) supplémentaire à la
finalisation, négligeable pour N ≤ ~14 volumes (10 GB / 768 MB).
alternatives_considered:
- "Validation unique au build — rejeté : INV-286-08 demande explicitement une assertion à la finalisation"
- "Validation unique au runtime — rejeté : laisse passer des erreurs build-time qui pourraient être catchées tôt par TypeScript / tests d'orchestrator"
trade_offs:
- "+ Dernier rempart fail-closed même en cas de bypass du builder"
- "+ Cohérent avec l'esprit `defense in depth` du contract"
- "- Légère duplication entre `buildPvproofMetadata` (validation complète) et `assertMultiVolumeMetadataConsistent` (ré-assertion structurelle uniquement)"
- "- O(N) à la finalisation, négligeable en pratique"
6. Hypothèses confirmées et points de vigilance
6.1 Hypothèses du plan validées
- H-PD286-PLAN-04 (compatibilité format
.pvproof) : confirmé par préservation de tous les tests pvproof-assembler-class.test.ts (PD-85, 8/8 passent). L'ajout de volumes_count / assembled_from[] dans pvproof.json est purement additif — un consommateur PD-85 strict qui parse pvproof.json ignorera ces clés inconnues (JSON tolère les clés additionnelles). La validation byte-identical par un parser PD-85 réel relève des tests d'intégration C11 (TC-NR-02).
6.2 Points de vigilance pour l'orchestrator (C7)
L'orchestrator app DOIT respecter les invariants suivants pour ne pas dégrader la garantie INV-286-08 :
- Construire la métadonnée AVANT de commencer le pipeline d'écriture — appeler
buildPvproofMetadata({...}) une fois après réception de la réponse API multi-volumes. Si le builder throw, transitionner vers state FAILED AVANT toute écriture ZIP. Ne pas attendre finalize() pour découvrir une incohérence. - Appeler
addPvproofMetadata(metadata) plutôt que addPvproofJson(rawObject) dans la branche multi-volumes. La forme typée garantit l'assertion runtime (INV-286-08). - Conserver la branche legacy : sur un export single-volume, l'orchestrator existant (PD-85) appelle
addPvproofJson(rawObject) directement. Cette branche reste valide et ne doit PAS être migrée vers addPvproofMetadata (INV-286-05 — pvproof.json byte-identical). - Ne JAMAIS muter la métadonnée entre construction et
addPvproofMetadata — les types readonly aident, mais le runtime ne l'empêche pas (Object.freeze non appliqué pour ne pas pénaliser le tooling JSON).
6.3 Dette technique laissée en l'état
| Sujet | Description | Action recommandée |
Missing volumeIndex branch | La branche Missing volumeIndex: ${i} dans buildPvproofMetadata est mathématiquement inatteignable étant donné les checks amont (cardinalité + unicité + bornes ⇒ couverture pigeonhole). Conservée comme defense in depth. | Pas d'action immédiate — supprimer si une revue future préfère retirer le code mort. La branche n'est pas couverte par le coverage tests (volontaire). |
Object.freeze sur métadonnée | La métadonnée n'est pas figée — un caller pourrait théoriquement muter assembled_from[i] après buildPvproofMetadata. Le risque est faible (TS empêche au compile-time via readonly) mais existe au runtime. | Si C11 ajoute des tests adversariaux runtime, freeze récursif possible. ROI faible. |
pvproof_format_version bumping | H-PD286-PLAN-04 mentionne un bump éventuel à version: 2. Non implémenté ici (hors périmètre §10 plan, conditionnel à TC-NR-02). | Story de suivi si TC-NR-02 révèle une incompatibilité. |
7. Vérification locale
| Vérification | Commande | Résultat |
| Tests unitaires C9 | npx jest src/export/__tests__/pvproof-assembler.test.ts | 36/36 passés (~570 ms) |
| Tests sibling PD-85 | npx jest src/export/__tests__/pvproof-assembler-class.test.ts | 8/8 passés (régression — aucune) |
| Type-check fichiers livrés | npx tsc --noEmit 2>&1 \| grep pvproof-assembler | 0 erreur |
Note : npx tsc --noEmit global remonte des erreurs préexistantes dans d'autres modules (src/__tests__/AppNavigator.test.tsx, src/services/biometricKeychain.qwen.test.ts, src/crypto/aes-gcm.ts, etc.). Aucune n'est imputable à C9 — toutes préexistent à mon travail et sortent du périmètre du contract app-pvproof-assembler-multi.
8. Hors périmètre (signalé, non modifié)
- Intégration dans l'orchestrator (C7) : remplacement de
assembler.addPvproofJson(pvproofMeta) par assembler.addPvproofMetadata(buildPvproofMetadata({...volumes})) dans la branche multi-volumes — responsabilité de l'agent app-export-orchestrator-multi (C7). - Vérification d'intégrité par volume :
volume-verifier.verify() est appelé par C7 AVANT appendVolume/addFileFromDisk ; pas dans le scope de C9. - Hashing final du
.pvproof : hashFileStreaming(outputPath) reste appelé par l'orchestrator post-finalize() (PD-85 inchangé). - Test E2E
.pvproof consommé par parser PD-85 : couvert par C11 (suite tests d'intégration TC-NR-02). - Bumping
pvproof_format_version : conditionnel à TC-NR-02, story de suivi si nécessaire.