Aller au contenu

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 :

  1. La state machine déterministe d’un case et toutes ses transitions
  2. Le router skill-based scoré et son algorithme
  3. Le moteur SLA Temporal (workflow CaseSlaWorkflow)
  4. Le principe 4-eyes intégré
  5. L’audit trail append-only signé (Ed25519 par acteur)
  6. Le schéma de données PostgreSQL avec RLS
  7. Les API REST admin et agent
  8. Les events Kafka émis et consommés

Flow nominal (~secondes à minutes selon priorité) :

  1. Event Kafka entrant déclenche la création d’un Case avec subject, triggers, priority.
  2. Router calcule un score d’adéquation par agent disponible et assigne au top-1.
  3. Workflow Temporal CaseSlaWorkflow démarre avec deadline calculé par (priority × tenant).
  4. Agent traite le case via API (poll, claim, comment, attach, decide).
  5. Si décision sensible (montant > seuil, PEP, ESCALATED) : 4-eyes engine exige une seconde signature distincte.
  6. Décision finale émise sur Kafka case.decided ; events tracés append-only signés.

ÉtatTransitions sortantes validesActeur
NEWASSIGNED (router) ; AUTO_REJECTED (timeout)système
ASSIGNEDIN_REVIEW (claim agent) ; ASSIGNED (re-routed)agent ou système
IN_REVIEWDECISION_PENDING ; PAUSED ; ESCALATED ; ASSIGNED (renoncement)agent
DECISION_PENDINGAPPROVED ; REJECTED ; ESCALATED (sans 4-eyes confirmation)agent + 4-eyes
PAUSEDIN_REVIEW ; ASSIGNED (timeout pause)agent
ESCALATEDASSIGNED (router L2 ou MLRO)système
APPROVEDCLOSED (post-actions)système
REJECTEDCLOSED (post-actions)système
AUTO_REJECTEDCLOSEDsystème
CLOSEDterminal

Invariants stricts :

  • Aucune transition non listée n’est valide → IllegalStateTransitionException côté serveur.
  • Une fois CLOSED, le case est immutable. Pour amender, ouvrir un nouveau case lié (linkedTo: oldCaseId, relation: REOPEN_OF).
  • ESCALATED → ASSIGNED est la seule façon de “redescendre” — implémentée comme un re-routing vers un agent de séniorité supérieure.

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
)
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 = true
Filter: agent.tenantId = case.tenantId
Filter: agent.seniority >= case.minSeniority
Filter: agent.currentLoad < agent.maxLoad
Filter: 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.

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.


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 }
@WorkflowInterface
interface 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).

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 REJECTED finale (pour éviter rejet abusif unilatéral)
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 { ... }
}
  1. Agent A propose APPROVED ou REJECTED → état DECISION_PENDING.
  2. Si 4-eyes requis : le case est routé vers un second agent éligible (queue dédiée “to-confirm”).
  3. Agent B examine, peut confirmer ou rejeter la proposition.
  4. Si confirmé : APPROVED ou REJECTED final. Si rejeté : retour IN_REVIEW avec commentaire de B.

Le serveur enforce que les signatures soient de deux agents différents. Pas possible de bypasser via API.


@Serializable
data 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
)
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 enforcement
REVOKE UPDATE, DELETE ON case_event FROM app_role;

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 :

  1. Récupère tous les events ordonnés par occurredAt.
  2. Vérifie le chaînage prev.hash == curr.previousEventHash pour chaque paire consécutive.
  3. Vérifie chaque signature Ed25519 avec la public key de l’acteur (key ring per-tenant).
  4. 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.


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
);

MéthodeEndpointDescription
GET/v1/cases/queue?state=ASSIGNEDfile d’attente personnelle
POST/v1/cases/:id/claimpasser à IN_REVIEW
POST/v1/cases/:id/commentajouter commentaire (interne ou externe)
POST/v1/cases/:id/attachmentupload document
POST/v1/cases/:id/pausepasser à PAUSED (avec motif)
POST/v1/cases/:id/resumepasser à IN_REVIEW
POST/v1/cases/:id/propose-decisionproposer APPROVED ou REJECTED
POST/v1/cases/:id/confirm-decision4-eyes : confirmer ou rejeter la proposition
POST/v1/cases/:id/escalateescalade vers L2 ou MLRO
POST/v1/cases/:id/reassignrenoncer (max 2× par case)
GET/v1/cases/:id/audittimeline append-only signée
MéthodeEndpointDescription
GET/v1/cases?state=...&priority=...&q=...recherche multi-critères + full-text
GET/v1/cases/kpis?period=last_7dKPIs SLA, throughput, taux décision
POST/v1/cases/bulk-decidebulk operation (MLRO)
POST/v1/agents/:id/availabilityactiver / désactiver
GET/v1/policies/caserécupère la CasePolicy du tenant
PUT/v1/policies/casemet à jour (avec dual-control + audit)

Spec complète : voir /api/openapi/ section Case Management. Contract tests Pact en CI.


TopicÉmis parEffet
bio.verdict.publishedbio-svccrée case si verdict = MANUAL_REVIEW_REQUIRED
risk.evaluation.publishedrisk-matrix-svccrée case si level ∈ {HIGH, PROHIBITED}
aml.alert.publishedaml-svccrée case selon politique tenant
TopicSchémaQuand
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).


KPIDéfinitionCible MVP
Throughputcases CLOSED / jour / agentmesuré, baseline tenant
SLA respect (STANDARD)% de cases résolus avant deadline≥ 90 %
First-response time p50temps NEW → IN_REVIEW≤ 30 min STANDARD
Resolution time p50temps 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 signatures100 %
Reopen rate% de cases CLOSED rouverts via REOPEN_OF≤ 3 %

Dashboard Grafana inclus en MVP (cf Operations / Executive Dashboard).


  • 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).

MetricCible MVPCible V2
Latence assignment p95≤ 200 ms≤ 100 ms
Throughput création cases≥ 200 /min /tenant≥ 1000 /min
Charge concurrente agents100 agents simultanés500
Audit verify-chain p95 (1000 events)≤ 2 s≤ 500 ms
Disponibilité99,5 %99,9 %

ItemMVP (V0)V2 (S+12)
Bulk operations (MLRO)basiqueavancé avec preview + rollback
Notificationsemail + Kafka+ SMS + Teams / Slack
Auto-décision (timeout LOW)OFFON configurable
Search full-textILIKE PostgresOpenSearch ICU multilingue
Pause/reprisemanuelautomatique sur reception document
AI assistnonsuggestions de décision via LLM (résumé case + recherche jurisprudence)
Mobile agent appnoniOS/Android (lecture + commentaires uniquement)

  • State machine implémentée + tests 100 % des transitions
  • Router skill-based avec scoring déterministe + fairness round-robin
  • CaseSlaWorkflow Temporal 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)


Document de spec Case Management — version 1.0 (2026-04-27). Mises à jour bloquantes nécessitent un ADR.