Aller au contenu

POC Form Designer — DSL Kotlin + évaluateur + JSON canonique

POC : poc-form-designer/ (~700 lignes Kotlin). Construit en référence pour la spec Form Designer — moteur et l’ADR-027.

Status : 18/18 tests passants. Démo CLI fonctionnelle.

Ce POC démontre les invariants critiques du moteur Form Designer en code exécutable :

  1. DSL Kotlin type-safe avec invariants enforcés à la construction (DslMarker + require() agrégés)
  2. JSON canonique RFC 8785 (clés triées, encoding strict) + hash SHA-256 stable
  3. Évaluateur pur isomorphe (cible Kotlin/JVM, port TS prévu V1)
  4. Validateur publish enforçant I9–I19 (CPS, traductions, consent, signatures, cycle, enums)
  5. Validation deletion avec blocage si CPS path est consommé par une policy de risque

poc-form-designer/
├── build.gradle.kts // Kotlin 2.0, kotlinx.serialization, JUnit 5
├── src/main/kotlin/io/vitakyc/form/
│ ├── Model.kt // FormDefinition, StepDef, FieldDef, Predicate AST, Action, Resolution, erreurs typées
│ ├── Dsl.kt // FormDefinitionBuilder + StepBuilder + FieldBuilder + RuleBuilder + PredicateScope
│ ├── Evaluator.kt // pure fun evaluate(form, submission) -> Resolution
│ ├── Validator.kt // PublishValidator (I9–I19) + validateDeletion (CPS in-use)
│ ├── CanonicalJson.kt // RFC 8785 encode + SHA-256 hash
│ ├── Templates.kt // FORM_KYC_INDIVIDUAL v2.7.0 (3 steps, 2 règles, FR/AR/EN)
│ └── Main.kt // démo CLI 8 étapes
└── src/test/kotlin/io/vitakyc/form/
└── FormTest.kt // 18 tests (DSL, Evaluator, Validator, JSON)

InvariantTestComportement
formId regex [A-Z][A-Z0-9_]{2,63}DSL refuse formId invalidejet FormDslError.InvalidFormId
version SemVer MAJOR.MINOR.PATCHtemplate build OKjet InvalidVersion sinon
FieldId unique dans le formDSL refuse fieldId duplicateDuplicateFieldId
Field non-internal_only doit avoir cpsPathDSL refuse field sans cpsPathMissingCpsPath
Erreurs DSL agrégées en bloc(cas multi-erreurs)AggregatedDslErrors
InvariantTestComportement
Toute clé i18n référencée existepublish refuse i18n key absentMissingI18nKey
Chaque locale activée a sa traductionpublish refuse traduction AR manquanteMissingTranslation
Consentement présent dans la step consent.stepId(couvert template)MissingConsent
cpsPath connu CPS ou déclaré par ce formpublish refuse cpsPath inconnu et non déclaréUnknownCpsPath (ici accepte car déclaré)
Dual-control ≥ 2 signatures Ed25519publish refuse 1 seule signatureInsufficientSignatures
Suppression d’un cpsPath consommé par une policyvalidateDeletion bloque cpsPath en usageDeleteInUse
CasTestEffet
Adulte hors USEvaluator — adulte non USstep step_us_indicia cachée
Adulte US-bornEvaluator — adulte US-bornstep step_us_indicia visible (rule show_us_indicia)
MineurEvaluator — mineur bloquéblocked=true, blockMessage="error.minor.notEligible"
AND/OR composésrègles AND OR évaluées correctementOR matche dès qu’un membre est vrai
Action Required dynamiqueEvaluator — required dynamiqueajoute/retire de requiredFields selon prédicat
Action SkipStepEvaluator — skipStepretire la step de visibleSteps, ajoute à skippedSteps
CasTestEffet
Sérialisation déterministeJSON canonique — clés triées et hash stableoutput identique à invocations multiples
Clés triées lexicalementidemconsent < i18n < locales < meta < rules < steps
Hash SHA-256 stableidemsha256:<64 hex> reproductible

val form = formDefinition {
meta {
formId = "FORM_KYC_INDIVIDUAL"
tenantId = "TN-BANQUEX"
version = "2.7.0"
}
locales("fr", "ar", "en")
i18n {
"step.identity.title" fr "Identité" ar "الهوية" en "Identity"
"field.firstName.label" fr "Prénom" ar "الاسم" en "First name"
"error.minor.notEligible" fr "Mineur non éligible" ar "قاصر غير مؤهل" en "Minor not eligible"
// ...
}
step("step_identity") {
title = "step.identity.title"
field("first_name") {
type = FieldType.STRING
cpsPath = "client.firstName"
required = true
minLength = 2
regex = """^[\p{L} '-]{2,80}$"""
}
field("us_birth") {
type = FieldType.BOOLEAN
cpsPath = "client.usBirth"
required = true
}
}
step("step_us_indicia") {
title = "step.usIndicia.title"
visibleIfRule = "show_us_indicia"
field("us_address") {
type = FieldType.STRING
cpsPath = "client.usAddress"
}
}
rule("show_us_indicia") {
whenever { field("us_birth") eq true }
then = visible("step_us_indicia", visible = true)
}
rule("block_minor") {
whenever { ageLt("client.dob", years = 18) }
then = block(message = "error.minor.notEligible")
}
consent { stepId = "step_consent"; version = "1.0" }
}

=== VitaKYC POC Form Designer — démo ===
Form construit : FORM_KYC_INDIVIDUAL v2.7.0 (3 steps, 2 règles)
[Step 1] Validate publish — CPS connu, dual-control 2 sig
✅ Form publishable
[Step 2] Validate publish — 1 seule signature → REJET
✅ rejeté comme attendu : insufficient signatures: 1 (need >= 2)
[Step 3] Évaluer une soumission adulte hors US
blocked=false, visibleSteps=[step_consent, step_identity]
trace : []
[Step 4] Évaluer une soumission adulte US-born
blocked=false, visibleSteps=[step_consent, step_identity, step_us_indicia]
trace : [show_us_indicia:visible(step_us_indicia=true)]
[Step 5] Évaluer une soumission mineur
blocked=true, blockMessage=error.minor.notEligible
[Step 6] JSON canonique + hash
canonicalSize=3076 bytes
hash=sha256:3edbe52ba32e7d5d6b2ac22412fd54f566db71381b658acac7631fbd440ee4fb
[Step 7] Tentative suppression d'un cpsPath en cours d'utilisation
✅ DeleteInUse correctement détecté : cpsPath 'client.firstName' deletion blocked, in use by: policy:TN-BANQUEX@2.1#dim=client#rule=3
[Step 8] Champ sans cpsPath et non internalOnly → DSL refuse
✅ DSL rejette : field 'orphan' is not internal_only and requires cpsPath

  • Port TypeScript isomorphe (form-engine-web) avec golden tests cross-langage 80 cas
  • Signatures Ed25519 réelles (KMS Vault) — le POC se contente de compter signatureCount ≥ 2
  • Cycle detection avancé (au-delà de step.visibleIfRule) avec actions skipStep chaînées
  • Catalogue d’enums (BCT_PROFESSION_CODES, ISO3166, etc.) — extension à un service enum-catalog-svc
  • Persistence PostgreSQL append-only + Row-Level Security (cf ADR-002)
  • Kafka emit form.published après publish — POC s’arrête à la validation
  • Diff structurel entre versions (added/removed/renamed fields)
  • Compilation cache CDN (préchauffage au publish) pour latence cold-start client

Fenêtre de terminal
cd poc-form-designer
./gradlew test # 18/18 tests verts en ~1 min
./gradlew run # démo CLI 8 étapes

Dépendances : Kotlin 2.0.20, JVM 17, kotlinx-serialization-json 1.7.3, JUnit 5.11, AssertJ 3.26.