Skip to content

Pre-flight risk

Périmètre : évaluation anticipée du risk-engine au step risk_evaluation du wizard, avant la saisie PIN et le confirm. Source de référence : ticket NEX-438 (Epic 8 — pre-flight). Date : 2026-05-12.


1. Motivation

Avant cette feature, le risk-engine était invoqué uniquement par ConfirmIntentUseCase (après PIN). Conséquence : un user pouvait saisir tous les éléments d'une transaction qui allait être refusée à la fin — mauvaise UX, étapes inutiles, support sollicité pour expliquer le refus.

Le pre-flight lance le pipeline risque dès que l'utilisateur arrive sur l'étape risk_evaluation du flow. La décision est :

  • affichée dans le wizard avant review_screen + pin_challenge ;
  • persistée sur l'intent (metadata.preflightDecision) ;
  • relue par le confirm qui court-circuite le pipeline si blocked.

2. API

POST /v1/intents/:id/preflight-risk

  • Auth : Bearer (même PermissionGuard que transactions.create).
  • Body : aucun.
  • Réponse : PreflightDecision (cf. §3).

L'endpoint exécute :

  1. getIntent(id) — l'intent doit exister, être à l'initiateur authentifié, statut created ou otp_verified.
  2. strategy.resolveAccounts(intent) — délégué à la IntentConfirmationStrategy correspondante (générique pour les 8 IntentTypes).
  3. riskEngineService.evaluateV2(...) — pipeline complet (éligibilité → identité → plafonds → behavioral).
  4. Persistance via ledgerService.updateIntentFields({ metadata: { preflightDecision } }).

Idempotence : si metadata.preflightDecision existe déjà sur l'intent, le useCase retourne la décision cachée sans réappeler le risk-engine. L'idempotence côté risk-engine (24h Stripe-style) gère les cas où le client réappelle malgré tout.


3. Format de la décision

ts
interface PreflightDecision {
  decision: 'approved' | 'manual_review' | 'blocked';
  riskScore: number;            // 0–100
  reasons: ReadonlyArray<{
    rule: string;               // ex: "limits.per_transaction"
    severity: 'low' | 'medium' | 'high' | 'critical';
    i18nKey: string;            // ex: "risk.reason.per_transaction_limit_exceeded"
    context: Record<string, unknown>;
  }>;
  evaluationId: string;         // FK vers risk_evaluations
  policyId: string;             // snapshot policy au moment du preflight
  evaluatedAt: string;          // ISO 8601
}

3.1 Sémantique des 3 décisions

DécisionComportement UIComportement confirm
approvedWizard continue (PIN puis confirm).Cache hit risk-engine → exécution normale.
manual_reviewWizard continue avec warning visible.Intent terminé en pending_review après confirm.
blockedBouton "Continuer" remplacé par un UAlert rouge.Court-circuit : intent failed avec failure_reason = reasons[0].i18nKey.

4. Court-circuit côté ConfirmIntentUseCase

ts
const preflight = this.readPreflightDecision(intent);
if (preflight?.decision === 'blocked') {
  await this.failIntent(intentId, initiatorUserId, preflight.reasons[0]?.i18nKey ?? 'risk_blocked_preflight');
  throw new TransactionBlockedException(/* … */);
}
// sinon → flux normal (le risk-engine retournera le cache hit Stripe-style)

4.1 TOCTOU & re-évaluation

Le pre-flight n'autorise rien — il anticipe seulement. Au confirm, le pipeline est re-exécuté ; les éventuels changements de contexte (nouveau plafond, KYC validé entre-temps, blacklist mise à jour) sont pris en compte.

Le cache idempotence du risk-engine (24h) garantit que si le payload est identique au pre-flight, le coût d'appel est négligeable. Sinon, une re-évaluation complète s'exécute.


5. Intégration UI — StepRiskEvaluation

Le composant apps/cmms/layers/5-admin/app/components/sandbox-flow/StepRiskEvaluation.vue :

  1. À onMounted, appelle store.preflightRisk() qui POST sur l'endpoint.
  2. Pendant le loading : spinner + message "Évaluation du risque…".
  3. Au résultat :
    • approved → badge vert + bouton "Continuer".
    • manual_review → badge orange + warning.
    • blocked → alert rouge bloquante + raisons listées.
  4. Affiche la liste des reasons[] avec :
    • badge de sévérité (low / medium / high / critical),
    • label FR humanisé via humanizeFailureReason(),
    • identifiant technique rule en mono pour debug.

L'intent doit exister avant que le composant soit monté : la page sandbox/flow.vue s'en charge en appelant ensureIntentCreated() dans onAmountNext quand l'étape suivante est risk_evaluation (générique pour les 7 flows actifs).


6. Humanisation des motifs

Le failure_reason stocké sur l'intent (et les reasons[].i18nKey du pre-flight) est une clé i18n technique du type risk.reason.<code>. La couche admin (vue listing + détail + step risk) les rend lisibles via apps/cmms/layers/2-operations/app/utils/helpers/humanize-failure-reason.ts.

Stratégie de résolution (par priorité) :

  1. FR_LABELS — mapping court CMMS dédié aux vues admin, sans placeholders. ~24 codes couverts à date.
  2. Fallback RISK_REASON_CATALOG.fr — template officiel @nex/shared-utils avec neutralisés en .
  3. String brute — pour les erreurs techniques non-i18n (ex : Solde insuffisant: …).

Étendre FR_LABELS quand un nouveau code apparaît dans RISK_REASON_CATALOG. Un test every-risk-key-has-label est à ajouter en V2 pour garantir l'exhaustivité.


7. Migration metadata côté ledger

Pour persister la décision, UpdateIntentFieldsInput (orchestrator) et le endpoint POST /internal/intents/:id/update-fields (ledger-wallets) acceptent désormais un champ metadata?: Record<string, unknown>.

Le ledger applique un merge (pas un remplacement) :

ts
intent.metadata = { ...(intent.metadata ?? {}), ...fields.metadata };

Ainsi, les autres clés posées par les IntentCreationStrategy (ex : merchantCode, qrToken, snapshots de policy) ne sont jamais écrasées.


8. Limites connues

  • Le pre-flight nécessite que l'intent soit déjà créé, donc amount et la résolution destinataire doivent être validés en amont. Pour les flows futurs qui mettraient risk_evaluation AVANT amount_input, cette logique devra être adaptée (création lazy avec montant à 0 + re-eval au confirm).
  • Le failure_reason posé au court-circuit confirm est la première raison du pre-flight ; pour les transactions bloquées par plusieurs règles, seul le top-1 apparaît dans l'audit trail. Les autres restent dans metadata.preflightDecision.reasons[].
  • Pas encore de monitoring dédié (Prometheus) sur le taux blocked vs approved pre-flight ; à ajouter quand les volumes le justifieront.

9. Références

  • useCase : services/orchestrator/src/application/use-cases/intents/preflight-risk.use-case.ts
  • Court-circuit : services/orchestrator/src/application/use-cases/intents/confirm-intent.use-case.ts
  • Ledger merge metadata : services/ledger-wallets/src/intents/intents.controller.ts
  • Composant UI : apps/cmms/layers/5-admin/app/components/sandbox-flow/StepRiskEvaluation.vue
  • Helper humanisation : apps/cmms/layers/2-operations/app/utils/helpers/humanize-failure-reason.ts
  • Architecture flows : /architecture/transaction-flows-config-driven
  • Simulateur CMMS : /architecture/sandbox-flow-simulator
  • Architecture risk-engine : _bmad-output/planning-artifacts/architecture-risk-engine.md

Nex — Plateforme fintech CEMAC