Aller au contenu

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. Conformité au code contract

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 :

  1. 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.
  2. Appeler addPvproofMetadata(metadata) plutôt que addPvproofJson(rawObject) dans la branche multi-volumes. La forme typée garantit l'assertion runtime (INV-286-08).
  3. 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).
  4. 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.