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) → EXPIREDquandexpires_at < now(), mono-instance viapg_try_advisory_lock.- Migration
*-pd286-export-multi-volumes— type ENUM PostgreSQLexport_state_enum, tablevault_secure.export_sessions, trigger fail-closedprevent_forbidden_export_transition, triggerupdated_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 + exportExportStateMachinepour 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_decisionsdu 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ésiduellescomplaint-file-response.dto.tsetmerkle-proof-v2.controller.tssont 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 viacrypto.randomUUID()(learning 2026-02-21, anti S2245). - Aucun
.catch(() => logger.error(...))sur appel audit (learning 2026-03-08 anti-catch-absorb). - Aucun import de
canonicalizeou autre lib JCS (réservé à C3/C8). - Schéma
vault_securecohé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.findOne → stateMachine.transition → manager.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 serviceExportStateMachinene 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).