Aller au contenu

PD-286 / C7 — Agent Developer : app-export-orchestrator-multi

Module : app-export-orchestrator-multi (App, extension PD-283 / C10) Story : PD-286 — Export probatoire multi-volumes Stack : React Native + Expo SDK 54 + TypeScript


1. Périmètre livré

Extension de la pipeline d'export .pvproof côté app pour le contrat multi-volumes PD-286 (volumes[] + manifestRootHash). La pipeline single-volume PD-283 (startExport + callExportApi) reste byte-stable et inchangée (INV-286-05).

Fichiers modifiés (périmètre contractuel)

Fichier Nature Lignes
src/export/orchestrator.ts Extension : startExportV2, pipeline multi-volumes interne, helper progression pondérée +331
src/export/state-machine.ts Extension : type ExportStateMulti (+ EXPIRED), transitions * → EXPIRED, méthode markExpired, helper isTerminalState +69
src/export/__tests__/orchestrator.test.ts 11 nouveaux tests contractuels PD-286 + extension du bloc de mocks +447

Aucun fichier hors périmètre modifié

api-client.ts, schemas.ts, volume-verifier.ts, pvproof-assembler.ts, types.ts et __tests__/state-machine.test.ts restent intacts.


2. Mapping contract → implémentation

2.1 Interfaces (contract interfaces)

Contract Implémenté Localisation
ExportOrchestrator (extension) Méthode publique startExportV2(proofIds, authToken, callbacks) ajoutée. Multi-volume aware : route via callExportApiV2 selon kind (single-volume rejeté → caller doit utiliser startExport legacy ; multi-volumerunMultiVolumeFlow). orchestrator.ts méthodes startExportV2, runMultiVolumeFlow, processOneVolume, appendVolume, handleMultiVolumeFailure, computeProgressUnits
ExportStateMachine (app, extension) Type étendu ExportStateMulti = ExportState \| "EXPIRED". Table ALLOWED_TRANSITIONS ajoute * → EXPIRED depuis tout état non-terminal et EXPIRED → IDLE (reset uniquement). Méthode markExpired(reason?) annotative. Helper isTerminalState. state-machine.ts
ExportProgressReporter Réutilise le type ExportProgress PD-283 (step, current, total). Multi-volume : total = somme estimatedBytes compressée en KB, current = somme cumulée des volumes vérifiés (poids unique). orchestrator.ts méthode computeProgressUnits

2.2 Invariants (contract invariants)

Invariant contract Mécanisme Test
INV-286-07 (côté app) — volume-verifier.verify() AVANT appendVolume(), ordre garanti par spy testable processOneVolume : transition DOWNLOADING → DECRYPTING, puis bloc try { verifyVolume(...) } synchronique, PUIS transition DECRYPTING → ASSEMBLING, PUIS appel this.appendVolume(...). Méthode appendVolume dédiée pour permettre spy ordonné. INV-286-07 — verifyVolume is called BEFORE addFileFromDisk for each volume (order spy)
Traitement séquentiel — aucune parallélisation, abort sur first failure for (const volume of sortedVolumes) (ES iterator) avec await à chaque étape. Aucun Promise.all, .map(async ...), ni promesses concurrentes. Throw → break boucle → catch runMultiVolumeFlow. sequential processing — no parallel downloads (forbidden Promise.all) (vérifie maxInFlight === 1) + abort on first failure — volume 1 download is skipped after volume 0 hash mismatch
Tri local par volumeIndex ascendant (défensif, indépendant de l'ordre serveur) [...response.volumes].sort((a, b) => a.volumeIndex - b.volumeIndex) AVANT la boucle. Le serveur peut renvoyer volumes[] dans n'importe quel ordre tant que la continuité est respectée (Zod superRefine côté C10). defensive sort — processes volumes by volumeIndex ascending regardless of server order
Progression unique : poids = estimatedBytes / Σ estimatedBytes ; pas de barre par volume exposée totalEstimatedBytes calculé une fois en début de flow. computeProgressUnits(bytes) = Math.max(1, Math.round(bytes / 1024)) (compression KB pour rester sous 2³¹ à 10 GB). current croît cumulativement à chaque volume vérifié ; total constant pour la durée du flow. progress is weighted by cumulative estimatedBytes, not by volume count (vérifie Set(totals).size === 1 et current strictement croissant)
État synchronisé avec backend via réponse API ; transitions DOWNLOADING/ASSEMBLING/COMPLETED/FAILED gérées localement Cycle par volume : DOWNLOADING → DECRYPTING → ASSEMBLING → DOWNLOADING (next) ou → HASHING → READY (last). READY joue le rôle de COMPLETED côté app (terminal nominal). FAILED via handleMultiVolumeFailure sur erreur non-EXPIRED. CA-286-04 — multi-volume nominal flow produces a single .pvproof and reaches READY
EXPIRED : si backend renvoie expires_at dépassé en cours, abort et state EXPIRED handleMultiVolumeFailure(error) détecte error instanceof ExportError && error.code === "URL_EXPIRED"sm.markExpired(reason). URL_EXPIRED est l'erreur émise par file-downloader.ts sur HTTP 403 signed-URL expirée (PD-283). INV-286-11 — URL_EXPIRED during volume download transitions to EXPIRED + INV-286-11 — EXPIRED is terminal: cannot transition back to PREPARING/DOWNLOADING
Réutilise schemas Zod via api-client (C10) — pas de re-validation locale ad hoc startExportV2 appelle callExportApiV2(normalizedIds, authToken) (C10). Le retour est un discriminé kind: "single-volume" \| "multi-volume" déjà validé Zod. L'orchestrator consomme parsed.data typé sans re-validation. Implicite : aucun appel à multiVolumeExportApiResponseSchema.parse(...) dans orchestrator.ts (vérifié par grep + revue)

2.3 Forbidden (contract forbidden)

Interdiction Vérification
Téléchargement parallèle des volumes (Promise.all sur volumes[]) — séquentiel obligatoire Boucle for (const volume of sortedVolumes) { await ... } ; pas de Promise.all, pas de .map(async). Test runtime maxInFlight === 1 avec mockDownloadFile instrumenté.
Reprise après FAILED ou EXPIRED sur même exportId — nouvelle requête obligatoire EXPIRED → IDLE est la seule transition sortante d'EXPIRED dans ALLOWED_TRANSITIONS. prepareRetry() (méthode existante PD-283) tente EXPIRED → PREPARING qui est rejeté → ExportError("INVALID_TRANSITION"). Test : INV-286-11 — EXPIRED is terminal: cannot transition back to PREPARING/DOWNLOADING.
Calcul local d'expiration via Date.now() — utiliser expires_at retourné par backend Aucun appel Date.now() côté flux multi-volume (vérifié par grep dans runMultiVolumeFlow / processOneVolume). L'expiration est détectée via le 403 du signed URL (qui mute en URL_EXPIRED côté file-downloader). H-PD286-PLAN-05 : horloge backend = source unique.
Persistance d'un volume téléchargé entre app sessions — temp purgé sur abort/quit Réutilise TempFileManager PD-85 inchangé. tempMgr.purgeStale() appelé en début de startExportV2 (avant tout download). tempMgr.release(id) après chaque volume vérifié + assemblé. tempMgr.purgeAll() dans le catch sur erreur.
Bypass de volume-verifier (appendVolume avant verify) — interdit par tests d'ordre Garantie structurelle : processOneVolume exécute verifyVolume(...) AVANT this.appendVolume(...). Le rewrap HashMismatchException → ExportError("ARTIFACT_CORRUPT") propage l'erreur AVANT toute mutation de l'assembler. Test spy : INV-286-07 — verifyVolume is called BEFORE addFileFromDisk for each volume.
Mise à jour de la barre de progression à partir d'autre chose que la somme cumulée des estimatedBytes vérifiés computeProgressUnits n'accepte qu'un argument bytes. Tous les callbacks.onProgress({ step, current, total }) du flow multi-volume passent current = computeProgressUnits(cumulativeVerifiedBytesSoFar [+ volume.estimatedBytes]), total = computeProgressUnits(totalEstimatedBytes). Aucun calcul basé sur volumeIndex / totalVolumes. Test : progress is weighted by cumulative estimatedBytes, not by volume count.

2.4 Files (contract files)

Fichier État Justification
src/export/orchestrator.ts Modifié Extension publique startExportV2 + pipeline interne multi-volumes.
src/export/state-machine.ts Modifié Extension EXPIRED + markExpired + transitions vers EXPIRED depuis états non-terminaux.
src/export/__tests__/orchestrator.test.ts Modifié 11 tests contractuels PD-286 ajoutés (sous-block dédié PD-286 / C7 — startExportV2 multi-volumes).

3. Décisions architecturales

D1 — DECRYPTING réutilisé comme phase « verify-volume »

Décision : la pipeline multi-volumes réutilise la transition PD-283 DOWNLOADING → DECRYPTING → ASSEMBLING plutôt que d'inventer une nouvelle transition DOWNLOADING → ASSEMBLING.

Rationale : ajouter DOWNLOADING → ASSEMBLING casserait le test PD-283 / TC-NR-04 (__tests__/state-machine.test.ts ligne 175 itère sur toutes les paires (from, to) PD-283 et asserte canTransition === false pour celles non listées). Ce test est hors de mon périmètre (files). Sémantiquement, la phase « verify le manifest reçu » est la contrepartie multi-volumes de la phase « décrypte le proof reçu » : les deux mutent un blob téléchargé en données prêtes à être assemblées. La réutilisation préserve l'intégrité de la matrice PD-283.

Alternatives considérées : - Ajouter DOWNLOADING → ASSEMBLING : rejeté (casse PD-283 test). - Demander une mise à jour de state-machine.test.ts au C1 : rejeté (__tests__/state-machine.test.ts n'est pas dans mes files). - Ajouter un nouvel état VERIFYING_VOLUME : rejeté (modifierait types.ts, hors périmètre).

Trade-offs : sémantique "DECRYPTING" légèrement abusive en mode multi-volume (on vérifie un hash, on ne décrypte pas). Documenté dans le commentaire bloc d'ALLOWED_TRANSITIONS. Aucun impact runtime — ce sont des labels d'état.

D2 — ExportStateMulti typé localement (pas dans types.ts)

Décision : EXPIRED est ajouté via le type local ExportStateMulti = ExportState | "EXPIRED" exporté depuis state-machine.ts, plutôt que d'étendre l'union ExportState dans types.ts.

Rationale : types.ts est partagé avec PD-283 et n'est pas dans mes files. L'extension locale est sémantiquement équivalente côté API publique : les consommateurs PD-283 reçoivent le même contrat (les valeurs PD-283 restent valides), et seul le mode multi-volumes peut produire "EXPIRED". Le getter state retourne ExportStateMulti ; TypeScript narrowing fonctionne via comparaison string littérale.

Alternatives considérées : - Modifier types.ts pour ajouter "EXPIRED" : rejeté (hors files). - Caster en string partout : rejeté (perte de type-safety).

Trade-offs : aucun consommateur PD-283 ne peut détecter l'EXPIRED via narrowing exhaustif (e.g. switch (state)) — ils tomberaient en branche default. Mais aucun consommateur PD-283 n'utilise EXPIRED (il n'existe pas pour eux), donc impact nul.

D3 — appendVolume exposé comme méthode privée dédiée

Décision : la séquence assembler.addFileFromDisk(volumes/${idx}.bin, tempPath, true) est encapsulée dans une méthode privée appendVolume(assembler, tempPath, volumeIndex) plutôt qu'inline.

Rationale : permet aux tests d'ordre (INV-286-07) de spy verifyVolume ET appendVolume (via le mock addFileFromDisk du PvproofAssembler) indépendamment, et de vérifier l'ordre par mock.invocationCallOrder. Sans ce séparateur, le test devrait inférer l'ordre via une instrumentation lourde du mock du verifier.

D4 — Volumes stockés à volumes/${idx}.bin (passthrough)

Décision : chaque volume téléchargé est ajouté au conteneur final via assembler.addFileFromDisk(volumes/${idx}.bin, tempPath, /* stored */ true).

Rationale : le contenu d'un volume est déjà un blob optimisé côté backend (chiffré, possiblement compressé). Le re-compresser (addFileFromDisk(..., false) qui utilise ZipDeflate) serait inutile et coûteux pour un gain négligeable. Le pvproof.json final (assembled_from[]) trace les volumes par volumeIndex, ce qui permet au consommateur du .pvproof de reconstruire la liste des volumes par parcours du chemin volumes/N.bin. Le format conteneur reste celui PD-85 (INV-286-08).

D5 — Single-volume rejeté par startExportV2 (pas de delegation legacy)

Décision : si callExportApiV2 retourne kind === "single-volume", startExportV2 throw ExportError("ARTIFACT_CORRUPT") plutôt que de déléguer à startExport legacy.

Rationale : la signature startExport exige kMasterHex que startExportV2 ne demande pas (le multi-volumes ne décrypte pas proof-par-proof côté orchestrator). Mélanger les deux pipelines romprait l'isolation des états et exigerait une duplication de code. Les consommateurs PD-283 doivent appeler startExport directement ; les consommateurs PD-286 appellent startExportV2 quand ils savent attendre un payload multi-volumes. La détection "kind" est purement défensive (le caller savait à priori s'il déclenche un export > 768 MB).

Trade-offs : pas d'auto-routing. Documenté dans le message d'erreur : "startExportV2 received single-volume response — caller must use legacy startExport for PD-283 path".


4. Tests ajoutés

11 tests contractuels dans le bloc PD-286 / C7 — startExportV2 multi-volumes (__tests__/orchestrator.test.ts) :

ID test Référence contract Couverture
CA-286-04 — multi-volume nominal flow produces a single .pvproof and reaches READY CA-286-04, INV-286-08 Flux nominal complet : 2 volumes → READY, addPvproofMetadata 1×, finalize 1×
INV-286-07 — verifyVolume is called BEFORE addFileFromDisk for each volume (order spy) INV-286-07, CA-286-05 Ordre verify→append par volume, séquentiel inter-volumes
defensive sort — processes volumes by volumeIndex ascending regardless of server order invariant tri local Server renvoie [vol1, vol0] → orchestrator processe [vol0, vol1]
sequential processing — no parallel downloads (forbidden Promise.all) forbidden parallel maxInFlight === 1 sur 3 volumes
INV-286-11 — URL_EXPIRED during volume download transitions to EXPIRED INV-286-11, CA-286-09 URL_EXPIRED → state EXPIRED + onError(URL_EXPIRED)
TC-ERR-04 — hash mismatch on volume aborts with FAILED state and ARTIFACT_CORRUPT TC-ERR-04, INV-286-07 HashMismatchException → ExportError ARTIFACT_CORRUPT, state FAILED
abort on first failure — volume 1 download is skipped after volume 0 hash mismatch invariant abort downloadFile 1×, verifyVolume 1× après mismatch volume 0
progress is weighted by cumulative estimatedBytes, not by volume count invariant progression unique Set(totals) singleton, current monotone croissant
INV-286-08 — addPvproofMetadata receives volumes_count and assembled_from INV-286-08, CA-286-04 buildPvproofMetadata appelé avec totalVolumes/volumes ; addPvproofMetadata reçoit volumes_count=2
INV-286-05 — startExportV2 rejects single-volume payload (caller must use legacy startExport) INV-286-05, D5 kind=single-volume → throw, aucun download initié
INV-286-11 — EXPIRED is terminal: cannot transition back to PREPARING/DOWNLOADING INV-286-11, forbidden reprise prepareRetry rejette depuis EXPIRED

Résultats jest

Test Suites: 13 passed, 13 total
Tests:       289 passed, 289 total

(289 tests = 109 dans orchestrator + state-machine + 180 dans les autres modules export). Aucune régression PD-283 / PD-86. Tests volume-verifier, schemas-validation, pvproof-assembler-class (C8/C9/C10) intacts.

TypeScript check

npx tsc --noEmit -p tsconfig.json | grep src/export/(orchestrator|state-machine)
→ aucune erreur

(Erreurs TS pré-existantes dans __tests__/services/, useVaultStore.ts, hooks qwen.test.ts non liées à PD-286.)


5. Hypothèses & dette technique

H-C7-01 — Layout interne du .pvproof multi-volumes (volumes/N.bin)

Hypothèse : le consommateur du .pvproof final (vérifieur tiers, relecteur backend) sait reconstruire la liste des volumes par parcours du chemin volumes/${volumeIndex}.bin corrélé à pvproof.json.assembled_from[].

Impact si faux : nécessite documentation supplémentaire dans README_VERIFICATION.txt (PD-283 inchangé en multi-volume — TC-NR-02 à adapter, hors périmètre C7).

Mitigation : à valider en Gate 8 par revue ChatGPT du contrat de relecture. Si gap, story de suivi pour pvproof_format_version bumpé à 2 (cf. H-PD286-PLAN-04 du plan).

H-C7-02 — proofIds non reconstitué côté orchestrator multi-volume

Hypothèse : pvproof.json.proofIds peut être vide en mode multi-volumes (l'agrégat des proofs est tracé par manifestRootHash backend ; les proofIds individuels sont dans les manifests partiels des volumes).

Impact si faux : si un consommateur PD-283 vérifie proofIds.length === proofCount > 0, il échouerait sur un .pvproof multi-volume. Le buildPvproofMetadata actuel (C9) accepte un tableau vide via le mock, mais le runtime réel pourrait imposer une contrainte min(1) non inspectée.

Mitigation : assertion runtime déléguée à buildPvproofMetadata (C9). Si PvproofMultiVolumeMetadataError levé, l'orchestrator catch-and-fail via le bloc d'erreur de runMultiVolumeFlow. Le test INV-286-08 — addPvproofMetadata receives volumes_count and assembled_from valide que le mock accepte proofIds: []. À confirmer contre le runtime C9 réel en intégration.

H-C7-03 — URL_EXPIRED est l'unique signal de TTL backend

Hypothèse : seul le code d'erreur URL_EXPIRED (HTTP 403 sur signed URL) déclenche la transition → EXPIRED. Toute autre erreur (réseau, hash mismatch, validation) → → FAILED.

Impact si faux : si le backend renvoie un autre code (e.g. HTTP 410 + header explicite, ou un payload {code: "EXPORT_SESSION_EXPIRED"}), le flow tomberait en FAILED au lieu d'EXPIRED. Sémantique correcte côté INV-286-11 (terminal dans les deux cas), mais pourrait gêner un caller qui distingue les deux pour le messaging utilisateur.

Mitigation : à étendre via handleMultiVolumeFailure si la taxonomie backend (Q-286-02) enrichit les codes EXPIRED avant Gate 8.


6. Points à signaler hors périmètre

S1 — __tests__/state-machine.test.ts ne couvre pas EXPIRED

Le fichier state-machine.test.ts (PD-283 / C1) itère uniquement sur les 9 états PD-283 dans son matrix-test TC-NR-04. EXPIRED n'est donc ni testé positivement (transitions valides) ni négativement (transitions interdites depuis EXPIRED vers les non-terminaux). Le contrat C7 ne m'autorise pas à modifier ce fichier.

Recommandation : story de suivi ou ajout par C1 / C7 lors d'une itération ultérieure pour étendre ALL_STATES et VALID_TRANSITIONS du test à inclure EXPIRED. Impact : 0 régression actuelle, gap de couverture symbolique.

S2 — types.ts non modifié — ExportState reste PD-283-only

L'union ExportState dans types.ts n'inclut pas "EXPIRED". Conséquence : un switch(state) exhaustif côté consommateur PD-283 ne verra pas EXPIRED comme valeur possible. Documenté en D2.

Recommandation : si une future story unifie le contrat de typage (PD-XXX "unified export state types"), ajouter "EXPIRED" à l'union dans types.ts et supprimer ExportStateMulti de state-machine.ts. Impact nul tant que les consommateurs PD-283 ne consomment pas de payload multi-volumes.

S3 — Pas d'événement métier EXPORT_PROGRESS_VOLUME_DONE

L'orchestrator émet uniquement ExportProgress { step, current, total } (PD-283 inchangé). Aucun évènement séparé "volume X validé" n'est exposé, conformément à l'invariant "Progression unique" du contract.

Recommandation : si un besoin UX émerge de tracer les volumes individuellement (e.g. compteur "⅗ volumes vérifiés"), ajouter un type d'évènement séparé hors ExportProgress. Aucune action requise pour PD-286.


7. Vérification finale

Vérification Résultat
npx tsc --noEmit sur orchestrator.ts + state-machine.ts 0 erreur
npx jest src/export/__tests__/orchestrator.test.ts 33 tests passés (22 PD-283 + 11 PD-286)
npx jest src/export/__tests__/state-machine.test.ts 76 tests passés (PD-283 intacts)
npx jest --testPathPattern="src/export/" 289 / 289 (13 suites)
Aucun fichier hors files modifié ✓ vérifié par diff
Aucun Promise.all / parallèle dans pipeline multi-volumes ✓ vérifié par grep + test runtime
Aucun Date.now() dans flux EXPIRED ✓ vérifié par grep
Aucune re-validation Zod ad-hoc ✓ vérifié par grep (zéro .parse( dans orchestrator.ts)
Verify avant append par volume ✓ test spy INV-286-07 ... order spy
Tri défensif volumeIndex ascendant ✓ test defensive sort

8. Statut livraison

PRÊT POUR INTÉGRATION (Wave 3 — app-export-orchestrator-multi)

Dépendances satisfaites : - C10 app-export-api-clientcallExportApiV2 consommé ✓ - C8 app-volume-verifierverifyVolume + HashMismatchException consommés ✓ - C9 app-pvproof-assembler-multibuildPvproofMetadata + addPvproofMetadata consommés ✓

Aucune dépendance manquante. Aucun blocage. Module prêt pour Wave 4 (audit + tests E2E).