Aller au contenu

PD-286 — Livrable agent export-state-machine-backend (C4)

Story : Export probatoire multi-volumes (>768 MB jusqu'à 10 GB) Module : export-state-machine-backend (C4) — backend NestJS + TypeORM + PostgreSQL Wave : 1 (foundations) — pas de dépendance amont Agent : agent-developer (Claude)

1. Périmètre traité

Implémentation stricte du contrat C4 (cf. PD-286-code-contracts.yaml) :

  • ExportSession (entity TypeORM) — persistance d'une session d'export.
  • ExportStateMachine (service stateless) — matrice de transitions exhaustive (spec §4).
  • ExpiredExportWorker — cron 30 s * (non terminal) → EXPIRED quand expires_at < now(), mono-instance via pg_try_advisory_lock.
  • Migration *-pd286-export-multi-volumes — type ENUM PostgreSQL export_state_enum, table vault_secure.export_sessions, trigger fail-closed prevent_forbidden_export_transition, trigger updated_at, index, constraints CHECK.
  • Constantes (VOLUME_MAX_BYTES, MAX_TOTAL_EXPORT_BYTES, TTL bornes, tick worker, advisory lock key) ajoutées à export.constants.ts.
  • Wiring ExportModule (entité + providers + export ExportStateMachine pour les modules consommateurs C5/C6).

Hors périmètre (autres agents step 6b) : C1 partitioner, C2 DTO, C3 manifest builder, C5 controller, C6 audit, C7-C10 app, C11 tests.

2. Fichiers livrés

Fichier Statut Description
src/modules/export/enums/export-state.enum.ts nouveau Enum ExportState + TERMINAL_EXPORT_STATES + NON_TERMINAL_EXPORT_STATES
src/modules/export/enums/index.ts étendu Réexport ExportState (et constantes associées)
src/modules/export/entities/export-session.entity.ts nouveau Entité TypeORM + branded types ExportId, ExportSessionUserId
src/modules/export/state/export-state.machine.ts nouveau Service ExportStateMachine (canTransition / isTerminal / transition)
src/modules/export/workers/expired-export.worker.ts nouveau Worker @Interval 30 s, pg_try_advisory_lock, transitions transactionnelles
src/modules/export/export.constants.ts étendu Constantes PD-286 (volume max, total max, TTL, tick, advisory key)
src/modules/export/export.module.ts étendu Enregistrement entité + providers + export du service
src/database/migrations/1745000000000-PD286-ExportMultiVolumes.ts nouveau ENUM + table + 2 triggers + 3 index + comments

Aucun fichier hors src/modules/export/**, src/database/migrations/** n'a été modifié (cf. cross_module_protections: []).

3. Couverture des invariants & forbidden (code contract)

3.1 Invariants

Invariant code-contracts Mécanisme Localisation
INV-286-10 — matrice de transitions exhaustive ExportStateMachine.ALLOWED (Map) + canTransition() rejette tout couple absent state/export-state.machine.ts
INV-286-11 — COMPLETED/FAILED/EXPIRED terminaux États mappés à [] dans ALLOWED ; isTerminal() renvoie true ; TERMINAL_EXPORT_STATES exposé enums/export-state.enum.ts, state/export-state.machine.ts
Trigger PG prevent_forbidden_export_transition (defense in depth) Fonction PL/pgSQL + trigger BEFORE UPDATE OF state ; renvoie RAISE EXCEPTION (ERRCODE check_violation) si transition non listée migration
Worker EXPIRED tick 30 s, transition * (non terminal) → EXPIRED @Interval('pd286-expired-export', 30_000) + LessThan(now) + In(NON_TERMINAL_EXPORT_STATES) workers/expired-export.worker.ts
expires_at calculé au backend uniquement expires_at est une colonne NOT NULL renseignée par le créateur côté backend ; le worker compare via new Date() côté process backend (et la query SQL utilise now() côté Postgres lorsque on s'appuiera dessus côté C5). Aucun appel ne propage Date.now() côté app entity + worker
Transition persistée dans une transaction unique ExpiredExportWorker.expireOne enrobe transition() + manager.save() dans dataSource.transaction() ; même contrat exposé pour C5/C6 (le service ne persiste pas, c'est le caller qui gère la TX) worker
État stocké en colonne PostgreSQL ENUM export_state_enum CREATE TYPE vault_secure.export_state_enum AS ENUM (8 valeurs) + colonne state vault_secure.export_state_enum NOT NULL DEFAULT 'REQUESTED' migration

3.2 Forbidden

Forbidden Protection
UPDATE direct de export_sessions.state hors ExportStateMachine.transition() Trigger trg_prevent_forbidden_export_transition (BEFORE UPDATE OF state). Toute transition hors matrice lève check_violation même via SQL direct
Transition asynchrone post-response Le service transition() est synchrone, mute l'entité et délègue la persistance au caller dans la transaction métier (C5/C6)
Reprise depuis un état terminal : recréation de exportId obligatoire États terminaux mappés à [] ; canTransition(terminal, *) = false ; trigger PG le confirme côté DB
Worker EXPIRED multi-instance pg_try_advisory_lock(EXPIRED_WORKER_ADVISORY_LOCK_KEY) ; si non acquis, tick skippé silencieusement (tick_skipped_lock_held). Garde anti-réentrance intra-process via flag running
Calcul d'horloge basé sur Date.now() côté app pour décider de l'expiration backend L'expiration n'est jamais déclarée par le client : le worker backend transite en se basant sur expiresAt (colonne backend) et new Date() du processus backend. La clé expires_at retournée par C5 sera la source unique pour l'app

4. Décisions architecturales (decision trace)

Format imposé par §"Decision trace pendant l'implémentation" (ajout dans architectural_decisions du code contract module).

architectural_decisions:
  - decision: "Colonne TypeORM `state` typée `varchar(32)` côté entity malgré le type Postgres `export_state_enum`"
    rationale: |
      Le contrat impose un ENUM Postgres (porté par la migration et le trigger). Le mapping
      TypeORM utilise volontairement `varchar` plutôt que `type:'enum'` pour rester cohérent
      avec le pattern PD-103 (capture_events) et éviter le piège `ALTER TYPE ADD VALUE`
      identifié par les REX PD-282/PD-279 si une nouvelle valeur devait être ajoutée.
    alternatives_considered:
      - "type:'enum' + enumName:'export_state_enum' (TypeORM gère les diffs schema)"
      - "VARCHAR + CHECK constraint applicatif (pattern PD-253)  abandonné car le contrat exige ENUM"
    trade_offs: |
      Avantage : ALTER TYPE futur reste possible sans migration synchrone TypeORM.
      Inconvénient : la contrainte de domaine est portée par Postgres, pas par TypeScript ;
      l'entité accepte n'importe quelle string en RAM. Mitigation : `ExportStateMachine` est
      l'unique point d'écriture, et le trigger PG bloque les UPDATE hors matrice.

  - decision: "Worker piloté par `@Interval` plutôt que `@Cron`"
    rationale: |
      Tick 30 s, sub-minute. `@Cron` accepte des expressions sub-minute mais `@Interval`
      est sémantiquement plus juste pour un poll fixe et permet d'utiliser un nom unique
      (`'pd286-expired-export'`) pour l'introspection / l'arrêt sélectif.
    alternatives_considered:
      - "@Cron('*/30 * * * * *')"
      - "Worker dédié hors NestJS (pg-boss)  overkill pour un poll"
    trade_offs: |
      Avantage : simplicité, intégration directe ScheduleModule (déjà importé via app.module).
      Inconvénient : pas de jitter par défaut entre instances. Acceptable car un seul tick
      effectif grâce à `pg_try_advisory_lock` (les autres instances skippent).

5. Vérifications effectuées

  • npx tsc --noEmit -p tsconfig.json : 0 erreur sur les 8 fichiers livrés (les 2 erreurs résiduelles complaint-file-response.dto.ts et merkle-proof-v2.controller.ts sont hors périmètre — appartiennent respectivement au module C2 export-multi-dto et au module merkle).
  • Conventions :
  • Branded types (ExportId, ExportSessionUserId) — learning 2026-03-04 (UUID sémantiquement distincts).
  • Aucun Math.random() ; tout UUID est généré côté C5 via crypto.randomUUID() (learning 2026-02-21, anti S2245).
  • Aucun .catch(() => logger.error(...)) sur appel audit (learning 2026-03-08 anti-catch-absorb).
  • Aucun import de canonicalize ou autre lib JCS (réservé à C3/C8).
  • Schéma vault_secure cohérent avec PD-103 et autres tables sensibles.

6. Hypothèses d'implémentation (à valider en intégration)

ID Hypothèse Impact si faux
H-C4-01 Le createur de la session (C5 controller) renseigne expiresAt = now() + EXPORT_SESSION_TTL (heures, source backend) au moment du INSERT initial avec state = REQUESTED. Si C5 oublie le champ, l'INSERT échoue (NOT NULL). Vérifié par tests d'intégration C5.
H-C4-02 Le module @nestjs/schedule est déjà initialisé via ScheduleModule.forRoot() (vérifié dans src/app.module.ts:176). Si retiré, le worker ne tickera pas — error visible au démarrage.
H-C4-03 Le schéma vault_secure existe avant la migration PD-286 (créé par migrations antérieures, présent dans vault_secure.capture_events, vault_secure.bulk_exports, etc.). Si absent : CREATE TYPE vault_secure.export_state_enum échouera. À documenter en pré-condition de migration.
H-C4-04 Les transitions effectuées par C5/C6/C7 (côté backend) suivent toujours le pattern : repository.findOnestateMachine.transitionmanager.save à l'intérieur d'une transaction NestJS/TypeORM. Si un caller fait un UPDATE brut, le trigger PG bloquera (defense in depth) — fail-closed acceptable mais pénible à débugger. À couvrir par revues de code C5.

7. Observabilité

Log key Niveau Contenu
state_transition debug exportId, from, to
state_transition_rejected warn exportId, from, to (transition refusée par la machine)
tick_skipped_reentrancy debug tick courant skippé (autre tick déjà en cours dans le process)
tick_skipped_lock_held debug tick courant skippé (autre instance détient l'advisory lock)
expired_worker_due log count (nombre de sessions à expirer ce tick)
expired_worker_done log expired, failed (compteurs)
expired_worker_failure error exportId, reason (échec d'une transition individuelle, isolé)

INTERDIT (par learning 2026-03-08) : log de manifest, log de signedUrl, log de integrityHash complet — non concernés par C4 mais documentés pour cohérence.

8. Cohérence avec PD-85

PD-286 ajoute une session persistée (export_sessions) là où PD-85 ne traçait que des résultats synchrones (ExportResultState). Aucune route PD-85 n'est touchée par C4 ; le wiring du module conserve la signature ExportModule PD-85, ne casse pas les imports existants (ExportService, ExportManifestBuilder, etc.) et ajoute uniquement ExportSession aux TypeOrmModule.forFeature([...]).

9. Suivi pour les agents downstream

  • C5 (controller) : doit appeler ExportStateMachine.transition(session, PLANNED_SINGLE | PLANNED_MULTI | FAILED) dans la transaction qui INSERT la session, et émettre l'audit C6 dans la même TX (anti-catch-absorb).
  • C6 (audit) : append synchrone dans la même dataSource.transaction() que la transition. Le service ExportStateMachine ne persiste pas, donc C6 reste libre de l'ordre des opérations.
  • C7/C8/C9 (app) : la machine app est distincte (cf. plan §1.2 C7) ; aucun couplage runtime avec le service backend, mais la matrice doit rester identique entre les deux pour que les transitions backend ↔ app convergent. Drift à vérifier en intégration / TC-INV-10.
  • C11 (tests) : utiliser ExportStateMachine.listAllowedTransitions() (helper statique) pour itérer le produit cartésien dans TC-INV-10 ; le trigger PG doit valider exactement la même matrice (test d'intégration DB réelle).