Case Management — workflow agent compliance, SLA, audit signé
Module :
case-mgmt-svc(microservice JVM Kotlin + Temporal + PostgreSQL RLS).ADRs : ADR-001 (Temporal), ADR-002 (RLS), ADR-029 (case management).
Triggers en entrée :
bio.verdict.published(pipeline biométrique, cf ADR-028),risk.evaluation.published(cf ADR-025),aml.alert.published(transaction monitoring).POC : poc-case-mgmt (Kotlin pur, ~700 LOC, tests JUnit 5).
Cette page fixe la spec d’ingénierie complète du module Case Management. Un développeur qui la lit doit pouvoir construire case-mgmt-svc sans question résiduelle :
- La state machine déterministe d’un case et toutes ses transitions
- Le router skill-based scoré et son algorithme
- Le moteur SLA Temporal (workflow
CaseSlaWorkflow) - Le principe 4-eyes intégré
- L’audit trail append-only signé (Ed25519 par acteur)
- Le schéma de données PostgreSQL avec RLS
- Les API REST admin et agent
- Les events Kafka émis et consommés
1. Vue d’ensemble
Section intitulée « 1. Vue d’ensemble »Flow nominal (~secondes à minutes selon priorité) :
- Event Kafka entrant déclenche la création d’un
Caseavecsubject,triggers,priority. - Router calcule un score d’adéquation par agent disponible et assigne au top-1.
- Workflow Temporal
CaseSlaWorkflowdémarre avec deadline calculé par(priority × tenant). - Agent traite le case via API (poll, claim, comment, attach, decide).
- Si décision sensible (montant > seuil, PEP, ESCALATED) : 4-eyes engine exige une seconde signature distincte.
- Décision finale émise sur Kafka
case.decided; events tracés append-only signés.
2. State machine
Section intitulée « 2. State machine »2.1 Diagramme d’état
Section intitulée « 2.1 Diagramme d’état »2.2 Énumération des états et transitions
Section intitulée « 2.2 Énumération des états et transitions »| État | Transitions sortantes valides | Acteur |
|---|---|---|
NEW | ASSIGNED (router) ; AUTO_REJECTED (timeout) | système |
ASSIGNED | IN_REVIEW (claim agent) ; ASSIGNED (re-routed) | agent ou système |
IN_REVIEW | DECISION_PENDING ; PAUSED ; ESCALATED ; ASSIGNED (renoncement) | agent |
DECISION_PENDING | APPROVED ; REJECTED ; ESCALATED (sans 4-eyes confirmation) | agent + 4-eyes |
PAUSED | IN_REVIEW ; ASSIGNED (timeout pause) | agent |
ESCALATED | ASSIGNED (router L2 ou MLRO) | système |
APPROVED | CLOSED (post-actions) | système |
REJECTED | CLOSED (post-actions) | système |
AUTO_REJECTED | CLOSED | système |
CLOSED | terminal | — |
Invariants stricts :
- Aucune transition non listée n’est valide →
IllegalStateTransitionExceptioncôté serveur. - Une fois
CLOSED, le case est immutable. Pour amender, ouvrir un nouveau case lié (linkedTo: oldCaseId, relation: REOPEN_OF). ESCALATED → ASSIGNEDest la seule façon de “redescendre” — implémentée comme un re-routing vers un agent de séniorité supérieure.
3. Router skill-based scoré
Section intitulée « 3. Router skill-based scoré »3.1 Modèle
Section intitulée « 3.1 Modèle »enum class Skill { PEP, FATCA, CRYPTO, RETAIL, CORPORATE, TRANSACTION, CDD_ENHANCED }enum class Seniority { L1, L2, MLRO }enum class Priority { LOW, STANDARD, HIGH, URGENT }
data class AgentProfile( val agentId: String, val tenantId: String, val skills: Set<Skill>, val seniority: Seniority, val available: Boolean, val currentLoad: Int, // nombre de cases IN_REVIEW val maxLoad: Int // ex 25)
data class CaseRoutingInput( val caseId: String, val tenantId: String, val requiredSkills: Set<Skill>, val priority: Priority, val minSeniority: Seniority = Seniority.L1)3.2 Algorithme de scoring
Section intitulée « 3.2 Algorithme de scoring »score(agent, case) = skillMatch * 0.50 # |agent.skills ∩ case.requiredSkills| / |case.requiredSkills| + seniorityFit * 0.20 # 1 si seniority requise atteinte exactement, 0.5 si dépassée, 0 sinon + loadAvailable * 0.20 # max(0, 1 - currentLoad/maxLoad) + priorityBoost * 0.10 # 1.0 si priority URGENT et seniority ≥ L2 ; 0.0 sinon
Filter: agent.available = trueFilter: agent.tenantId = case.tenantIdFilter: agent.seniority >= case.minSeniorityFilter: agent.currentLoad < agent.maxLoadFilter: case.requiredSkills ⊆ agent.skills (sinon score=0 strict)
Pick: top-1 by score, tie-break by least-recently-assigned (round-robin fairness)Pourquoi ces poids : skillMatch domine (compétence métier > tout le reste). loadAvailable garantit qu’on ne sature pas un agent. seniorityFit privilégie L1 pour les cases L1, garde L2 pour L2 (n’utilise pas un MLRO comme L1). priorityBoost est marginal mais garantit qu’un URGENT va vers une séniorité suffisante.
3.3 Fairness round-robin
Section intitulée « 3.3 Fairness round-robin »Si plusieurs agents égaux en score (abs(diff) < 0.01), on assigne à celui dont la dernière assignation est la plus ancienne. Cela évite qu’un agent ait toute la charge alors qu’un collègue identique en skills reste à 0 cases.
4. SLA — workflow Temporal
Section intitulée « 4. SLA — workflow Temporal »4.1 Configuration tenant
Section intitulée « 4.1 Configuration tenant »sla: byPriority: URGENT: { firstResponseMin: 15, resolutionH: 1 } HIGH: { firstResponseMin: 60, resolutionH: 4 } STANDARD: { firstResponseMin: 240, resolutionH: 24 } LOW: { firstResponseMin: 1440, resolutionH: 168 } # 7 jours bySegment: RETAIL_TN: ... # peut overrider CRYPTO: ... # SLA souvent plus strict escalationLadder: - { afterRatio: 0.75, action: NOTIFY_AGENT } - { afterRatio: 1.00, action: NOTIFY_MANAGER } - { afterRatio: 1.25, action: AUTO_ESCALATE_L2 } - { afterRatio: 2.00, action: AUTO_ESCALATE_MLRO }4.2 Workflow signature
Section intitulée « 4.2 Workflow signature »@WorkflowInterfaceinterface CaseSlaWorkflow { @WorkflowMethod fun monitor(input: CaseSlaInput)
@SignalMethod fun onTransition(newState: CaseState, at: Instant)
@SignalMethod fun onPause()
@SignalMethod fun onResume()}
class CaseSlaWorkflowImpl : CaseSlaWorkflow { private val slaActivities = activityStub<SlaActivities>(timeout = 15.s, retries = 3) private var paused = false private var deadline: Instant = Instant.MAX
override fun monitor(input: CaseSlaInput) { deadline = computeDeadline(input.priority, input.startedAt, input.tenantId) for (rung in input.escalationLadder) { val rungDeadline = deadline + rung.delayFromBaseline Workflow.await { Instant.now() >= rungDeadline || paused.not() } if (caseStillOpen(input.caseId)) slaActivities.execute(rung.action, input.caseId) } }
override fun onTransition(newState: CaseState, at: Instant) { if (newState == CaseState.CLOSED) Workflow.completeWorkflow() if (newState == CaseState.IN_REVIEW) deadline = recomputeDeadline() }
override fun onPause() { paused = true } override fun onResume() { paused = false }}Garanties :
- Exactly-once des escalades, même en cas de redémarrage
case-mgmt-svc(Temporal persiste l’état du workflow). - Pause/reprise ne consomment pas le SLA — le timer s’arrête tant que le workflow attend
paused.not(). - Idempotence des actions d’escalade (un email ne part qu’une fois).
5. 4-eyes principle
Section intitulée « 5. 4-eyes principle »5.1 Quand applicable
Section intitulée « 5.1 Quand applicable »Le 4-eyes est exigé sur :
- Cases avec
subject.transactionAmount > tenant.fourEyesThreshold(ex : 100 000 TND par défaut) - Cases marqués
pep = true(politiquement exposé) - Cases
priority = URGENT - Cases
escalated = true(déjà passé par L1) - Toute décision
REJECTEDfinale (pour éviter rejet abusif unilatéral)
5.2 Engine
Section intitulée « 5.2 Engine »data class FourEyesPolicy( val tenantId: String, val triggers: List<FourEyesTrigger>, val signerRoleConstraints: SignerConstraints)
data class SignerConstraints( val differentAgent: Boolean = true, // toujours val differentSeniorityAllowed: Boolean = true, val secondMustBe: Set<Seniority> = setOf(Seniority.L2, Seniority.MLRO), val excludeSameTeam: Boolean = false // option tenant)
object FourEyesEngine { fun requires(case: Case, policy: FourEyesPolicy): Boolean { ... } fun canConfirm(case: Case, firstSigner: AgentId, secondSigner: AgentId, policy: FourEyesPolicy): Boolean { ... }}5.3 Workflow UX
Section intitulée « 5.3 Workflow UX »- Agent A propose
APPROVEDouREJECTED→ étatDECISION_PENDING. - Si 4-eyes requis : le case est routé vers un second agent éligible (queue dédiée “to-confirm”).
- Agent B examine, peut confirmer ou rejeter la proposition.
- Si confirmé :
APPROVEDouREJECTEDfinal. Si rejeté : retourIN_REVIEWavec commentaire de B.
Le serveur enforce que les signatures soient de deux agents différents. Pas possible de bypasser via API.
6. Audit trail append-only signé
Section intitulée « 6. Audit trail append-only signé »6.1 Modèle d’event
Section intitulée « 6.1 Modèle d’event »@Serializabledata class CaseEvent( val eventId: String, val caseId: String, val tenantId: String, val type: EventType, // CREATED, ASSIGNED, COMMENTED, ATTACHED, DECIDED, ESCALATED, ... val actor: ActorRef, // (kind: AGENT | SYSTEM, id, role) val payload: JsonObject, // état spécifique du type val occurredAt: String, // ISO-8601 val previousEventHash: String, // chainage (block-style) val hash: String, // SHA-256 du JSON canonique sans hash/signature val signature: Ed25519Signature // signature de l'acteur)6.2 Schéma SQL append-only
Section intitulée « 6.2 Schéma SQL append-only »CREATE TABLE case_event ( event_id UUID PRIMARY KEY, case_id UUID NOT NULL, tenant_id UUID NOT NULL, type VARCHAR(32) NOT NULL, actor_kind VARCHAR(16) NOT NULL, -- AGENT, SYSTEM actor_id VARCHAR(64), payload JSONB NOT NULL, occurred_at TIMESTAMPTZ NOT NULL, previous_event_hash CHAR(74) NOT NULL, hash CHAR(74) NOT NULL, signature TEXT NOT NULL, -- base64 CHECK (length(hash) = 74), CHECK (length(previous_event_hash) = 74));
-- RLS (cf ADR-002)ALTER TABLE case_event ENABLE ROW LEVEL SECURITY;CREATE POLICY case_event_tenant_isolation ON case_event USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
-- Append-only enforcementREVOKE UPDATE, DELETE ON case_event FROM app_role;6.3 Chaînage des hashes
Section intitulée « 6.3 Chaînage des hashes »Chaque event chaîne son previousEventHash au hash de l’event précédent du même caseId. Le premier event a previousEventHash = "sha256:0..." (zero hash). Un audit complet d’un case :
- Récupère tous les events ordonnés par
occurredAt. - Vérifie le chaînage
prev.hash == curr.previousEventHashpour chaque paire consécutive. - Vérifie chaque signature Ed25519 avec la public key de l’acteur (key ring per-tenant).
- Si un seul échec dans la chaîne → audit alert (tampering détecté).
Cette structure rend une falsification du log infaisable sans accès aux clés privées et à la chaîne entière.
7. Schéma de données
Section intitulée « 7. Schéma de données »CREATE TABLE case_record ( case_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, state VARCHAR(24) NOT NULL, priority VARCHAR(12) NOT NULL, required_skills TEXT[] NOT NULL, min_seniority VARCHAR(8) NOT NULL, subject_ref JSONB NOT NULL, -- { kind: CLIENT|TRANSACTION, id, displayName, ... } triggers JSONB NOT NULL, -- liste des events sources (bio, risk, aml) assigned_to VARCHAR(64), assigned_at TIMESTAMPTZ, decision VARCHAR(16), -- APPROVED | REJECTED | null decision_at TIMESTAMPTZ, decided_by VARCHAR(64), confirmed_by VARCHAR(64), -- 4-eyes paused BOOLEAN NOT NULL DEFAULT FALSE, linked_to UUID, -- relation à un autre case link_relation VARCHAR(24), -- REOPEN_OF, ESCALATED_TO, MERGED_INTO, ... created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL, closed_at TIMESTAMPTZ);
CREATE INDEX idx_case_state_priority ON case_record (tenant_id, state, priority, created_at);CREATE INDEX idx_case_assigned_to ON case_record (tenant_id, assigned_to, state);
ALTER TABLE case_record ENABLE ROW LEVEL SECURITY;CREATE POLICY case_record_tenant_isolation ON case_record USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
CREATE TABLE agent_profile ( agent_id VARCHAR(64) PRIMARY KEY, tenant_id UUID NOT NULL, skills TEXT[] NOT NULL, seniority VARCHAR(8) NOT NULL, available BOOLEAN NOT NULL DEFAULT TRUE, current_load INT NOT NULL DEFAULT 0, max_load INT NOT NULL DEFAULT 25, last_assigned_at TIMESTAMPTZ);8. API REST
Section intitulée « 8. API REST »8.1 Agent (UI back-office)
Section intitulée « 8.1 Agent (UI back-office) »| Méthode | Endpoint | Description |
|---|---|---|
GET | /v1/cases/queue?state=ASSIGNED | file d’attente personnelle |
POST | /v1/cases/:id/claim | passer à IN_REVIEW |
POST | /v1/cases/:id/comment | ajouter commentaire (interne ou externe) |
POST | /v1/cases/:id/attachment | upload document |
POST | /v1/cases/:id/pause | passer à PAUSED (avec motif) |
POST | /v1/cases/:id/resume | passer à IN_REVIEW |
POST | /v1/cases/:id/propose-decision | proposer APPROVED ou REJECTED |
POST | /v1/cases/:id/confirm-decision | 4-eyes : confirmer ou rejeter la proposition |
POST | /v1/cases/:id/escalate | escalade vers L2 ou MLRO |
POST | /v1/cases/:id/reassign | renoncer (max 2× par case) |
GET | /v1/cases/:id/audit | timeline append-only signée |
8.2 Admin / supervision
Section intitulée « 8.2 Admin / supervision »| Méthode | Endpoint | Description |
|---|---|---|
GET | /v1/cases?state=...&priority=...&q=... | recherche multi-critères + full-text |
GET | /v1/cases/kpis?period=last_7d | KPIs SLA, throughput, taux décision |
POST | /v1/cases/bulk-decide | bulk operation (MLRO) |
POST | /v1/agents/:id/availability | activer / désactiver |
GET | /v1/policies/case | récupère la CasePolicy du tenant |
PUT | /v1/policies/case | met à jour (avec dual-control + audit) |
8.3 OpenAPI
Section intitulée « 8.3 OpenAPI »Spec complète : voir /api/openapi/ section Case Management. Contract tests Pact en CI.
9. Events Kafka
Section intitulée « 9. Events Kafka »9.1 Consommés (entrée)
Section intitulée « 9.1 Consommés (entrée) »| Topic | Émis par | Effet |
|---|---|---|
bio.verdict.published | bio-svc | crée case si verdict = MANUAL_REVIEW_REQUIRED |
risk.evaluation.published | risk-matrix-svc | crée case si level ∈ {HIGH, PROHIBITED} |
aml.alert.published | aml-svc | crée case selon politique tenant |
9.2 Émis (sortie)
Section intitulée « 9.2 Émis (sortie) »| Topic | Schéma | Quand |
|---|---|---|
case.created | { caseId, tenantId, priority, triggers } | event 1 du case |
case.assigned | { caseId, agentId } | router termine |
case.decided | { caseId, decision, decidedBy, confirmedBy, decisionAt } | décision finale |
case.escalated | { caseId, fromAgent, toRouter, reason } | escalade |
case.closed | { caseId, finalState, closedAt } | fermeture |
Topic partition par tenantId. Rétention 30 jours côté broker (la trace longue est dans case_event).
10. KPIs et reporting
Section intitulée « 10. KPIs et reporting »| KPI | Définition | Cible MVP |
|---|---|---|
| Throughput | cases CLOSED / jour / agent | mesuré, baseline tenant |
| SLA respect (STANDARD) | % de cases résolus avant deadline | ≥ 90 % |
| First-response time p50 | temps NEW → IN_REVIEW | ≤ 30 min STANDARD |
| Resolution time p50 | temps NEW → CLOSED | ≤ 4 h STANDARD |
| Routing accuracy | % de cases assignés au top-1 vs L2 manual reassignment | ≥ 80 % |
| 4-eyes coverage | % de cases sensibles avec deux signatures | 100 % |
| Reopen rate | % de cases CLOSED rouverts via REOPEN_OF | ≤ 3 % |
Dashboard Grafana inclus en MVP (cf Operations / Executive Dashboard).
11. Sécurité
Section intitulée « 11. Sécurité »- Auth agent : OIDC tenant (Keycloak / Auth0 / IdP banque) avec MFA obligatoire.
- Signature Ed25519 : chaque agent a une keypair (clé privée stockée dans HSM / Vault, jamais sortie). Lors d’une action, le serveur signe l’event avec la clé du tenant pour le compte de l’agent (proof-of-action issued).
- RLS multi-tenant : Postgres RLS bloque tout accès cross-tenant (cf ADR-002).
- CSRF : tokens anti-CSRF côté UI.
- Rate limit : 30 req/min/agent, 100 req/min/tenant.
- Audit trail tamper-evident : chainage hash + signature → falsification détectable même par DBA.
- Pièces jointes : chiffrées AES-256-GCM, KEK per-tenant ; antivirus scan obligatoire avant stockage.
- Logs applicatifs : aucune PII en clair (subjectName tronqué, transactionAmount masqué selon rôle).
12. Performance et capacité
Section intitulée « 12. Performance et capacité »| Metric | Cible MVP | Cible V2 |
|---|---|---|
| Latence assignment p95 | ≤ 200 ms | ≤ 100 ms |
| Throughput création cases | ≥ 200 /min /tenant | ≥ 1000 /min |
| Charge concurrente agents | 100 agents simultanés | 500 |
| Audit verify-chain p95 (1000 events) | ≤ 2 s | ≤ 500 ms |
| Disponibilité | 99,5 % | 99,9 % |
13. Plan de migration MVP → V2
Section intitulée « 13. Plan de migration MVP → V2 »| Item | MVP (V0) | V2 (S+12) |
|---|---|---|
| Bulk operations (MLRO) | basique | avancé avec preview + rollback |
| Notifications | email + Kafka | + SMS + Teams / Slack |
| Auto-décision (timeout LOW) | OFF | ON configurable |
| Search full-text | ILIKE Postgres | OpenSearch ICU multilingue |
| Pause/reprise | manuel | automatique sur reception document |
| AI assist | non | suggestions de décision via LLM (résumé case + recherche jurisprudence) |
| Mobile agent app | non | iOS/Android (lecture + commentaires uniquement) |
14. Checklist go-live MVP
Section intitulée « 14. Checklist go-live MVP »- State machine implémentée + tests 100 % des transitions
- Router skill-based avec scoring déterministe + fairness round-robin
-
CaseSlaWorkflowTemporal opérationnel avec pause/resume signaux - 4-eyes engine intégré, impossibilité de bypasser via API
- Audit trail append-only avec chaînage hash + signatures Ed25519
- Schéma Postgres avec RLS testé cross-tenant
- API REST agent + admin avec OpenAPI publié
- Events Kafka consommés + émis selon contrat
- Dashboard KPI Grafana en place
- OIDC + MFA agent fonctionnel
- Pilote tenant : 200 cases réels traités, 5 agents, SLA mesuré ≥ 90 %
- Runbook on-call (incident SLA workflow timeout, audit chain rupture, Vault offline)
15. Références
Section intitulée « 15. Références »- ADR-001, ADR-002, ADR-004, ADR-025, ADR-028, ADR-029
- POC poc-case-mgmt
- Mockups workflow 11 — review queue + détail case + 4-eyes
- BCT Circulaire 2017-08 (LCB-FT) + 2018-07 (4-eyes)
- Loi 2015-26 (TN), 6AMLD (UE), FATF Reco. 1, 10–18, 21–23
Document de spec Case Management — version 1.0 (2026-04-27). Mises à jour bloquantes nécessitent un ADR.