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
PermissionGuardquetransactions.create). - Body : aucun.
- Réponse :
PreflightDecision(cf. §3).
L'endpoint exécute :
getIntent(id)— l'intent doit exister, être à l'initiateur authentifié, statutcreatedouotp_verified.strategy.resolveAccounts(intent)— délégué à laIntentConfirmationStrategycorrespondante (générique pour les 8 IntentTypes).riskEngineService.evaluateV2(...)— pipeline complet (éligibilité → identité → plafonds → behavioral).- Persistance via
ledgerService.updateIntentFields({ metadata: { preflightDecision } }).
Idempotence : si
metadata.preflightDecisionexiste 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
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écision | Comportement UI | Comportement confirm |
|---|---|---|
approved | Wizard continue (PIN puis confirm). | Cache hit risk-engine → exécution normale. |
manual_review | Wizard continue avec warning visible. | Intent terminé en pending_review après confirm. |
blocked | Bouton "Continuer" remplacé par un UAlert rouge. | Court-circuit : intent failed avec failure_reason = reasons[0].i18nKey. |
4. Court-circuit côté ConfirmIntentUseCase
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 :
- À
onMounted, appellestore.preflightRisk()qui POST sur l'endpoint. - Pendant le loading : spinner + message "Évaluation du risque…".
- Au résultat :
- approved → badge vert + bouton "Continuer".
- manual_review → badge orange + warning.
- blocked → alert rouge bloquante + raisons listées.
- Affiche la liste des
reasons[]avec :- badge de sévérité (
low/medium/high/critical), - label FR humanisé via
humanizeFailureReason(), - identifiant technique
ruleen mono pour debug.
- badge de sévérité (
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é) :
FR_LABELS— mapping court CMMS dédié aux vues admin, sans placeholders. ~24 codes couverts à date.- Fallback
RISK_REASON_CATALOG.fr— template officiel@nex/shared-utilsavecneutralisés en…. - 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) :
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
amountet la résolution destinataire doivent être validés en amont. Pour les flows futurs qui mettraientrisk_evaluationAVANTamount_input, cette logique devra être adaptée (création lazy avec montant à 0 + re-eval au confirm). - Le
failure_reasonposé 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 dansmetadata.preflightDecision.reasons[]. - Pas encore de monitoring dédié (Prometheus) sur le taux
blockedvsapprovedpre-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