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 :
- DSL Kotlin type-safe avec invariants enforcés à la construction (DslMarker +
require()agrégés) - JSON canonique RFC 8785 (clés triées, encoding strict) + hash SHA-256 stable
- Évaluateur pur isomorphe (cible Kotlin/JVM, port TS prévu V1)
- Validateur publish enforçant I9–I19 (CPS, traductions, consent, signatures, cycle, enums)
- Validation deletion avec blocage si CPS path est consommé par une policy de risque
1. Architecture du POC
Section intitulée « 1. Architecture du POC »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)2. Invariants démontrés
Section intitulée « 2. Invariants démontrés »2.1 Construction DSL (compile-time + build-time)
Section intitulée « 2.1 Construction DSL (compile-time + build-time) »| Invariant | Test | Comportement |
|---|---|---|
formId regex [A-Z][A-Z0-9_]{2,63} | DSL refuse formId invalide | jet FormDslError.InvalidFormId |
version SemVer MAJOR.MINOR.PATCH | template build OK | jet InvalidVersion sinon |
FieldId unique dans le form | DSL refuse fieldId duplicate | DuplicateFieldId |
Field non-internal_only doit avoir cpsPath | DSL refuse field sans cpsPath | MissingCpsPath |
| Erreurs DSL agrégées en bloc | (cas multi-erreurs) | AggregatedDslErrors |
2.2 Validateur publish
Section intitulée « 2.2 Validateur publish »| Invariant | Test | Comportement |
|---|---|---|
| Toute clé i18n référencée existe | publish refuse i18n key absent | MissingI18nKey |
| Chaque locale activée a sa traduction | publish refuse traduction AR manquante | MissingTranslation |
Consentement présent dans la step consent.stepId | (couvert template) | MissingConsent |
cpsPath connu CPS ou déclaré par ce form | publish refuse cpsPath inconnu et non déclaré | UnknownCpsPath (ici accepte car déclaré) |
| Dual-control ≥ 2 signatures Ed25519 | publish refuse 1 seule signature | InsufficientSignatures |
Suppression d’un cpsPath consommé par une policy | validateDeletion bloque cpsPath en usage | DeleteInUse |
2.3 Évaluateur isomorphe (déterministe, pur)
Section intitulée « 2.3 Évaluateur isomorphe (déterministe, pur) »| Cas | Test | Effet |
|---|---|---|
| Adulte hors US | Evaluator — adulte non US | step step_us_indicia cachée |
| Adulte US-born | Evaluator — adulte US-born | step step_us_indicia visible (rule show_us_indicia) |
| Mineur | Evaluator — mineur bloqué | blocked=true, blockMessage="error.minor.notEligible" |
| AND/OR composés | règles AND OR évaluées correctement | OR matche dès qu’un membre est vrai |
Action Required dynamique | Evaluator — required dynamique | ajoute/retire de requiredFields selon prédicat |
Action SkipStep | Evaluator — skipStep | retire la step de visibleSteps, ajoute à skippedSteps |
2.4 JSON canonique + hash
Section intitulée « 2.4 JSON canonique + hash »| Cas | Test | Effet |
|---|---|---|
| Sérialisation déterministe | JSON canonique — clés triées et hash stable | output identique à invocations multiples |
| Clés triées lexicalement | idem | consent < i18n < locales < meta < rules < steps |
| Hash SHA-256 stable | idem | sha256:<64 hex> reproductible |
3. Exemple de DSL extrait du POC
Section intitulée « 3. Exemple de DSL extrait du POC »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" }}4. Output démo CLI
Section intitulée « 4. Output démo CLI »=== 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 cpsPath5. Hors scope POC (à compléter en MVP)
Section intitulée « 5. Hors scope POC (à compléter en MVP) »- 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 actionsskipStepchaînées - Catalogue d’enums (
BCT_PROFESSION_CODES,ISO3166, etc.) — extension à un serviceenum-catalog-svc - Persistence PostgreSQL append-only + Row-Level Security (cf ADR-002)
- Kafka emit
form.publishedaprè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
6. Reproduire le POC
Section intitulée « 6. Reproduire le POC »cd poc-form-designer./gradlew test # 18/18 tests verts en ~1 min./gradlew run # démo CLI 8 étapesDépendances : Kotlin 2.0.20, JVM 17, kotlinx-serialization-json 1.7.3, JUnit 5.11, AssertJ 3.26.
7. Références
Section intitulée « 7. Références »- Form Designer — moteur d’exécution — spec engineering A-Z
- ADR-027 — décision moteur
- POC Risk Engine — partage la grammaire de prédicats
- POC CPS Registry — contrat consommé par le validateur publish