Form Designer — moteur d'exécution
Modules :
form-designer-svc(back-office éditeur, JVM),form-engine-jvm(DSL + évaluateur Kotlin),form-engine-web(SDK Web TypeScript),form-runtime-svc(publish / validate / serve definition).ADRs : ADR-007 (scope MVP), ADR-026 (intégration CPS), ADR-027 (moteur).
Pages liées : maquettes UX Form Designer, Client Profile Schema, POC Form Designer, workflow 3 mockups.
Ce document fixe la spec d’ingénierie complète du moteur Form Designer. Il s’adresse à un développeur fullstack (Kotlin/JVM + TypeScript/Web) qui doit pouvoir construire, sans question résiduelle :
- Le modèle interne (FormDefinition, StepDef, FieldDef, Rule, Action, Predicate)
- La représentation JSON canonique versionnée et signable
- Le DSL Kotlin côté serveur (designer + validation + tests + génération PDF récap)
- L’évaluateur isomorphe Kotlin/TypeScript et son protocole de golden tests
- Le pipeline de publication (validation invariants, signature dual-control, propagation Kafka)
- L’intégration CPS (déclaration des variables, refus suppression sous référence, events)
- Le runtime SDK Web (chargement, évaluation, état, persistence offline, RTL arabe)
- Les API REST publiques (admin) et privées (SDK)
1. Vue d’ensemble
Section intitulée « 1. Vue d’ensemble »Flow nominal : Designer édite → DSL/UI → JSON canonique → Validator → Signature → Store WORM → Kafka → CPS sync + Risk Matrix notification + SDK cache invalidation → SDK Web télécharge la nouvelle version active → Évaluateur TS rend les règles côté client.
2. Modèle interne
Section intitulée « 2. Modèle interne »2.1 Type tree
Section intitulée « 2.1 Type tree »// Spec d'AST partagée Kotlin / TypeScript — source de vérité
interface FormDefinition { meta: FormMeta; locales: Locale[]; // ex: ["fr","ar","en"] i18n: Record<I18nKey, I18nEntry>; steps: StepDef[]; // ordre = ordre d'affichage rules: Rule[]; // ordre = ordre d'évaluation consent: ConsentRef; // step + version + hash}
interface FormMeta { formId: FormId; // "FORM_KYC_INDIVIDUAL" tenantId: TenantId; version: SemVer; // "2.7.0" hash: Sha256; // SHA-256 sur JSON canonique sans hash/signature signatures: Ed25519Signature[]; // dual-control publishedAt: Iso8601; publishedBy: UserId[];}
type Locale = "fr" | "ar" | "en";type I18nKey = string; // dot-notation, ex: "step.identity.title"interface I18nEntry { fr: string; ar?: string; en?: string; } // AR / EN obligatoire si locale activée tenant
interface StepDef { id: StepId; title: I18nKey; description?: I18nKey; fields: FieldDef[]; // ordre = ordre d'affichage visibleIf?: PredicateRef; // Rule conditionnelle}
interface FieldDef { id: FieldId; // unique dans le form type: FieldType; label: I18nKey; hint?: I18nKey; errorMessage?: I18nKey; cpsPath?: CpsPath; // obligatoire sauf si internal_only=true internal_only: boolean; // ex: consent, notes internes required: boolean; constraints: Constraint[]; // min/max/regex/enum... prefillFrom?: CpsPath; // pré-remplissage depuis context accessibility: A11yAttrs;}
type FieldType = | "STRING" | "TEXT_LONG" | "INTEGER" | "DECIMAL" | "BOOLEAN" | "DATE" | "DOB" | "TIMESTAMP" | "EMAIL" | "PHONE" | "ENUM" | "ISO3166_ALPHA2" | "ISO4217" | "DOC_KYC" | "SELFIE" | "FILE" | "SIGNATURE" | "CONSENT" | "INDICIA_FATCA" | "PEP_DECLARATION";
interface Constraint { kind: "min" | "max" | "minLength" | "maxLength" | "regex" | "enumCatalog"; value: string | number;}
interface Rule { id: RuleId; // unique dans le form description?: string; predicate: Predicate; action: Action;}
// AST Predicate — sérialisable, isomorphe Kotlin/TStype Predicate = | { op: "eq" | "neq" | "lt" | "gt" | "lte" | "gte"; field: FieldId | CpsPath; value: ScalarValue } | { op: "in" | "notIn"; field: FieldId | CpsPath; values: ScalarValue[] } | { op: "contains"; field: FieldId | CpsPath; needle: string } | { op: "matches"; field: FieldId | CpsPath; regex: string } | { op: "isNull" | "notNull"; field: FieldId | CpsPath } | { op: "ageGte" | "ageLt"; field: FieldId | CpsPath; years: number } | { op: "and"; clauses: Predicate[] } | { op: "or"; clauses: Predicate[] } | { op: "not"; clause: Predicate };
type Action = | { kind: "visible"; target: StepId | FieldId; visible: boolean } | { kind: "required"; target: FieldId; required: boolean } | { kind: "skipStep"; target: StepId } | { kind: "block"; message: I18nKey } | { kind: "prefill"; target: FieldId; valueFrom: CpsPath };
type ScalarValue = string | number | boolean | null;type SemVer = `${number}.${number}.${number}`;type CpsPath = string; // "client.profession", "session.channel"2.2 Invariants (enforced à la construction DSL et à la publication)
Section intitulée « 2.2 Invariants (enforced à la construction DSL et à la publication) »| # | Invariant | Stade | Erreur |
|---|---|---|---|
| I1 | formId non vide, format [A-Z][A-Z0-9_]{2,63} | DSL build | InvalidFormId |
| I2 | version est un SemVer valide | DSL build | InvalidVersion |
| I3 | Tous les FieldId du form sont uniques | DSL build | DuplicateFieldId |
| I4 | Tous les StepId sont uniques | DSL build | DuplicateStepId |
| I5 | Tous les RuleId sont uniques | DSL build | DuplicateRuleId |
| I6 | Au moins une step | DSL build | EmptyForm |
| I7 | Chaque step a au moins un field, sauf step purement informationnelle marquée info=true | DSL build | EmptyStep |
| I8 | Chaque field non internal_only a un cpsPath | DSL build | MissingCpsPath |
| I9 | Le cpsPath existe dans le CPS du tenant ou sera déclaré (event sortant) | Publish | UnknownCpsPath |
| I10 | Toute I18nKey référencée existe dans i18n | Publish | MissingI18nKey |
| I11 | Pour chaque locale activée tenant, chaque I18nEntry a la clé | Publish | MissingTranslation |
| I12 | Predicate.field réfère un FieldId existant ou un CpsPath valide | DSL build | UnknownReference |
| I13 | Action.target réfère un FieldId/StepId existant | DSL build | UnknownTarget |
| I14 | Au moins une step contient un field type CONSENT ou la dernière step est consent.stepId | Publish | MissingConsent |
| I15 | Si type SIGNATURE requis pour le segment, présent dans le form | Publish | MissingSignature |
| I16 | Pas de cycle dans les règles visibleIf (DAG sur les steps) | Publish | RuleCycle |
| I17 | Les enumCatalog réfèrent des catalogues existants (BCT_PROFESSION_CODES, …) | Publish | UnknownEnumCatalog |
| I18 | Hash SHA-256 calculé après normalisation canonique JSON | Publish | autocalculé |
| I19 | Au moins 2 signatures Ed25519 distinctes (dual-control) sur l’envelope publish | Publish | InsufficientSignatures |
I1–I8, I12, I13 sont enforcés à la construction DSL via require() Kotlin → erreur compile-time / build-time. I9, I10, I11, I14–I17, I19 sont enforcés au publish par form-runtime-svc → erreur 422 avec liste détaillée.
2.3 Diagramme de classes simplifié
Section intitulée « 2.3 Diagramme de classes simplifié »3. JSON canonique — format publishable
Section intitulée « 3. JSON canonique — format publishable »3.1 Règles de canonicalisation (RFC 8785 strict)
Section intitulée « 3.1 Règles de canonicalisation (RFC 8785 strict) »- Encodage UTF-8 NFC, sans BOM
- Indentation 2 espaces,
\nséparateur de ligne (LF, jamais CRLF) - Clés des objets triées par ordre lexicographique sur le string Unicode code-point
- Pas de clés en doublon, pas de valeurs
undefined,nullautorisé seulement aux endroits prévus par le schéma - Nombres sérialisés sans zéros superflus (
1, pas1.0), notation scientifique interdite - Booléens en minuscule, chaînes échappées strictement (pas de surrogate UTF-16 séparé)
Le hash meta.hash est calculé sur la sérialisation canonique du document après mise à zéro des champs meta.hash et meta.signatures (canonicalisation puis SHA-256 hex). Les signatures Ed25519 portent sur ce hash.
3.2 Exemple compact
Section intitulée « 3.2 Exemple compact »{ "consent": { "hash": "sha256:9f1c...", "step_id": "step_consent", "version": "1.0" }, "i18n": { "field.country.label": { "ar": "البلد", "en": "Country", "fr": "Pays" }, "field.firstName.label": { "ar": "الاسم", "en": "First name", "fr": "Prénom" }, "step.identity.title": { "ar": "الهوية", "en": "Identity", "fr": "Identité" } }, "locales": ["fr", "ar", "en"], "meta": { "form_id": "FORM_KYC_INDIVIDUAL", "hash": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "published_at": "2026-04-25T09:42:00Z", "published_by": ["user-amine", "user-leila"], "signatures": [ { "key_id": "kms:tenant/TN-BANQUEX/admin/amine", "alg": "Ed25519", "sig": "BASE64..." }, { "key_id": "kms:tenant/TN-BANQUEX/compliance/leila", "alg": "Ed25519", "sig": "BASE64..." } ], "tenant_id": "TN-BANQUEX", "version": "2.7.0" }, "rules": [ { "action": { "kind": "visible", "target": "step_us_indicia", "visible": true }, "id": "show_us_indicia", "predicate": { "clauses": [ { "field": "us_birth", "op": "eq", "value": true }, { "field": "us_address", "op": "eq", "value": true } ], "op": "or" } } ], "steps": [ { "fields": [ { "accessibility": { "aria_label": "field.firstName.label" }, "constraints": [ { "kind": "minLength", "value": 2 }, { "kind": "regex", "value": "^[\\p{L} '-]{2,80}$" } ], "cps_path": "client.firstName", "id": "first_name", "internal_only": false, "label": "field.firstName.label", "required": true, "type": "STRING" } ], "id": "step_identity", "title": "step.identity.title" } ]}3.3 Compatibilité SemVer
Section intitulée « 3.3 Compatibilité SemVer »| Niveau | Exemples | Effet pour le tenant |
|---|---|---|
MAJOR (2.x → 3.0) | suppression d’un champ, type changé, cpsPath renommé | rupture : SDK Web tenant doit accepter explicitement le bump |
MINOR (2.7 → 2.8) | ajout d’un nouveau champ optionnel, nouvelle step optionnelle, libellé d’erreur | non-rupture : SDK Web suit ^MAJOR.MINOR automatiquement |
PATCH (2.7.0 → 2.7.1) | correction libellé, hint, branding, regex assouplie | non-rupture, déployé immédiatement |
Le SDK Web déclare un constraint côté embed (<vitakyc-form min-version="2.7.0">) ; le serveur sert la version ^constraint la plus récente publiée et active.
4. DSL Kotlin — form-engine-jvm
Section intitulée « 4. DSL Kotlin — form-engine-jvm »4.1 Architecture
Section intitulée « 4.1 Architecture »form-engine-jvm/├── ast/│ ├── FormDefinition.kt // data classes pures, sérialisables kotlinx-serialization│ ├── Predicate.kt // sealed class hierarchy, polymorphic JSON│ └── Action.kt // sealed class hierarchy├── dsl/│ ├── FormDefinitionBuilder.kt // @DslMarker, builders avec require()│ ├── StepBuilder.kt│ ├── FieldBuilder.kt│ ├── RuleBuilder.kt│ └── PredicateDsl.kt // infix operators eq/neq/lt/gt/and/or/not├── canonical/│ ├── CanonicalJson.kt // RFC 8785 serializer│ └── HashSigner.kt // SHA-256 + Ed25519 wrap├── validate/│ ├── PublishValidator.kt // I9–I19│ └── ValidationError.kt // sealed class typed errors└── eval/ ├── Evaluator.kt // pure function : (FormDefinition, Submission) -> Resolution └── Resolution.kt4.2 Exemple complet de DSL
Section intitulée « 4.2 Exemple complet de DSL »val formIndividualV2 = formDefinition { meta { formId = "FORM_KYC_INDIVIDUAL" tenantId = "TN-BANQUEX" version = "2.7.0" }
locales("fr", "ar", "en")
i18n { "step.identity.title" translates ("Identité" to "fr"; "الهوية" to "ar"; "Identity" to "en") "field.firstName.label" translates ("Prénom" to "fr"; "الاسم" to "ar"; "First name" to "en") "field.country.label" translates ("Pays" to "fr"; "البلد" to "ar"; "Country" to "en") "field.usBirth.label" translates ("Né(e) aux États-Unis ?" to "fr"; "وُلد في الولايات المتحدة؟" to "ar"; "Born in the US?" to "en") "error.minor.notEligible" translates ("Client mineur non éligible" to "fr"; "العميل القاصر غير مؤهل" to "ar"; "Minor client not eligible" to "en") "step.consent.title" translates ("Consentement" to "fr"; "الموافقة" to "ar"; "Consent" to "en") }
step("step_identity") { title = "step.identity.title" field("first_name") { type = STRING cpsPath = "client.firstName" label = "field.firstName.label" required = true minLength = 2 regex = """^[\p{L} '-]{2,80}$""" } field("country") { type = ISO3166_ALPHA2 cpsPath = "client.country" label = "field.country.label" required = true } field("us_birth") { type = BOOLEAN cpsPath = "client.usBirth" label = "field.usBirth.label" required = true } }
step("step_us_indicia") { title = "step.usIndicia.title" visibleIf = ref("show_us_indicia") field("us_address") { type = STRING cpsPath = "client.usAddress" label = "field.usAddress.label" } }
step("step_consent") { title = "step.consent.title" field("consent") { type = CONSENT internal_only = true label = "field.consent.label" required = true } }
rule("show_us_indicia") { description = "Affiche la step indicia FATCA si signaux US présents" `when` { field("us_birth") eq true } then = visible("step_us_indicia", visible = true) }
rule("block_minor") { description = "Bloque la soumission si client mineur" `when` { ageLt("client.dob", years = 18) } then = block(message = "error.minor.notEligible") }
consent { stepId = "step_consent" version = "1.0" }}4.3 Invariants de DSL
Section intitulée « 4.3 Invariants de DSL »Chaque builder lève une IllegalStateException typée si un invariant I1–I8, I12, I13 est violé. Le formDefinition { } final exécute une passe de cohérence (références croisées Predicate↔Field, Action↔Step) qui agrège les erreurs et les jette en bloc plutôt qu’à la première rencontrée.
sealed class FormDsLError(message: String) : IllegalStateException(message) { class DuplicateFieldId(val id: FieldId) : FormDsLError("Field id duplicated: $id") class MissingCpsPath(val fieldId: FieldId) : FormDsLError("Field $fieldId requires cpsPath") class UnknownTarget(val ruleId: RuleId, val target: String) : FormDsLError("Rule $ruleId targets unknown $target") class UnknownReference(val ruleId: RuleId, val ref: String) : FormDsLError("Rule $ruleId references unknown $ref") class EmptyForm : FormDsLError("Form has no step") class InvalidVersion(val raw: String) : FormDsLError("Version $raw not SemVer") class AggregatedDslErrors(val errors: List<FormDsLError>) : FormDsLError("${errors.size} DSL errors")}5. Évaluateur isomorphe — Kotlin & TypeScript
Section intitulée « 5. Évaluateur isomorphe — Kotlin & TypeScript »5.1 Signature
Section intitulée « 5.1 Signature »// Kotlin (form-engine-jvm)fun evaluate(form: FormDefinition, submission: Submission): Resolution
data class Submission( val values: Map<FieldId, ScalarValue>, val cpsContext: Map<CpsPath, ScalarValue>, val locale: Locale, val now: Instant)
data class Resolution( val visibleSteps: Set<StepId>, val visibleFields: Set<FieldId>, val requiredFields: Set<FieldId>, val skippedSteps: Set<StepId>, val blocked: Boolean, val blockMessage: I18nKey?, val prefilled: Map<FieldId, ScalarValue>, val ruleTrace: List<RuleTrace> // pour debug et audit)
data class RuleTrace(val ruleId: RuleId, val matched: Boolean, val effect: String)// TypeScript (form-engine-web) — signatures équivalentesexport function evaluate(form: FormDefinition, submission: Submission): Resolution;
export interface Submission { values: Record<FieldId, ScalarValue>; cpsContext: Record<CpsPath, ScalarValue>; locale: Locale; now: string; // ISO-8601}
export interface Resolution { visibleSteps: Set<StepId>; visibleFields: Set<FieldId>; requiredFields: Set<FieldId>; skippedSteps: Set<StepId>; blocked: boolean; blockMessage?: I18nKey; prefilled: Record<FieldId, ScalarValue>; ruleTrace: RuleTrace[];}5.2 Algorithme
Section intitulée « 5.2 Algorithme »evaluate(form, submission): # 1. Initial state — tout visible, rien required, rien blocked resolution = Resolution( visibleSteps = form.steps.map(::id).toSet(), visibleFields = form.steps.flatMap(::fields).map(::id).toSet(), requiredFields = form.steps.flatMap(::fields).filter { it.required }.map(::id).toSet(), skippedSteps = emptySet(), blocked = false, prefilled = emptyMap(), ruleTrace = [] )
# 2. Pré-passe : prefill (les valeurs prefill peuvent influencer les autres règles) for rule in form.rules where rule.action.kind == "prefill": if evalPredicate(rule.predicate, submission): resolution.prefilled[rule.action.target] = submission.cpsContext[rule.action.valueFrom] ruleTrace += RuleTrace(rule.id, true, "prefill ${rule.action.target}")
# 3. Passe principale : visible / required / skipStep / block for rule in form.rules where rule.action.kind in {"visible","required","skipStep","block"}: matched = evalPredicate(rule.predicate, submission with prefilled) if matched: apply(rule.action, resolution) ruleTrace += RuleTrace(rule.id, true, rule.action.kind) else: ruleTrace += RuleTrace(rule.id, false, "")
# 4. visibleIf des steps for step in form.steps where step.visibleIf != null: if !evalPredicateRef(step.visibleIf, submission): resolution.visibleSteps -= step.id resolution.visibleFields -= step.fields.map(::id).toSet()
return resolutionPure : pas d’I/O, pas d’aléa, pas d’horloge implicite (now est dans Submission). Conséquence : deterministic, memoizable, testable.
5.3 Golden tests cross-langage
Section intitulée « 5.3 Golden tests cross-langage »Le corpus golden est un dossier golden/ versionné dans form-engine-spec/ :
golden/├── 001-empty-form-no-rules/│ ├── form.json # FormDefinition sérialisée canonique│ ├── submission.json # Submission inputs│ └── resolution.json # Resolution attendue├── 002-visible-rule-or/│ ├── form.json│ ├── submissions/│ │ ├── a-no-us.json # cas où aucune indicia US → step cachée│ │ └── b-us-birth.json # cas où us_birth=true → step visible│ └── resolutions/│ ├── a-no-us.json│ └── b-us-birth.json├── ...└── 080-mixed-prefill-block-skip/Le harness CI (gradle goldenTest côté JVM, pnpm test:golden côté TS) charge chaque dossier, exécute evaluate(form, submission), et compare bit-à-bit (après canonicalisation) le résultat avec resolution.json. Drift = build rouge.
Couverture cible MVP : 80 cas avec matrice d’opérateurs (eq/neq/lt/gt/in/contains/matches/and/or/not/ageGte) × actions (visible/required/skipStep/block/prefill) × cas dégénérés (champ absent, locale rare, prefill cascade).
6. Pipeline de publication
Section intitulée « 6. Pipeline de publication »6.1 Séquence
Section intitulée « 6.1 Séquence »6.2 Validation — détail des invariants publish
Section intitulée « 6.2 Validation — détail des invariants publish »| Invariant | Implémentation | Rejet |
|---|---|---|
I9 — cpsPath connu ou nouveau autorisé | Cross-check avec profile-schema-svc.snapshot(tenant) ; si absent et statut Form Designer = source légitime → ajout staged ; sinon refus | 422 UnknownCpsPath |
I10 — toute I18nKey référencée existe | Walk de tous les I18nKey cités dans steps/fields/rules vs form.i18n.keys | 422 MissingI18nKey |
| I11 — chaque entry a les langues activées | Pour chaque locale de form.locales, vérifier que chaque entry a la traduction non vide | 422 MissingTranslation |
| I14 — consentement présent | form.consent.stepId réfère une step existante avec un field type CONSENT | 422 MissingConsent |
| I15 — signature requise présente | Si le segment tenant requiert SIGNATURE (config tenant), au moins un field type SIGNATURE dans le form | 422 MissingSignature |
| I16 — pas de cycle | DAG topologique sur step.visibleIf et rule.action.skipStep | 422 RuleCycle |
| I17 — enums connus | Chaque enumCatalog référencé existe dans enum-catalog-svc | 422 UnknownEnumCatalog |
| I19 — dual-control | 2 signatures Ed25519 distinctes, keyIds différents, rôles différents (admin + compliance recommandé) | 422 InsufficientSignatures |
6.3 Storage et immutabilité
Section intitulée « 6.3 Storage et immutabilité »-- Schema PostgreSQL (cf ADR-002 multi-tenant RLS)CREATE TABLE form_version ( version_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, form_id VARCHAR(64) NOT NULL, version VARCHAR(16) NOT NULL, -- "2.7.0" schema_json JSONB NOT NULL, -- canonical form hash CHAR(74) NOT NULL, -- "sha256:xxxx" signatures JSONB NOT NULL, -- [{keyId, alg, sig}, ...] published_at TIMESTAMPTZ NOT NULL, published_by TEXT[] NOT NULL, is_active BOOLEAN NOT NULL DEFAULT FALSE, superseded_at TIMESTAMPTZ, UNIQUE (tenant_id, form_id, version), CHECK (length(hash) = 74), CHECK (cardinality(signatures::jsonb -> 'sigs') >= 2));
-- Append-only : aucun UPDATE ni DELETE n'est autorisé en prodREVOKE UPDATE, DELETE ON form_version FROM app_role;
-- Ledger d'activation (qui a basculé quoi quand)CREATE TABLE form_activation_log ( log_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, form_id VARCHAR(64) NOT NULL, activated_version VARCHAR(16) NOT NULL, previous_version VARCHAR(16), switched_at TIMESTAMPTZ NOT NULL, switched_by TEXT NOT NULL, reason TEXT);Une seule version is_active=true à la fois par (tenant_id, form_id) (contrainte partielle UNIQUE conditionnelle).
6.4 Kafka — événement form.published
Section intitulée « 6.4 Kafka — événement form.published »{ "schema": "form.published.v1", "tenant_id": "TN-BANQUEX", "form_id": "FORM_KYC_INDIVIDUAL", "version": "2.7.0", "previous_version": "2.6.3", "hash": "sha256:e3b0c44...", "published_at": "2026-04-25T09:42:00Z", "published_by": ["user-amine", "user-leila"], "diff": { "added_fields": [ { "field_id": "us_signatory", "cps_path": "client.usSignatory", "type": "BOOLEAN" } ], "removed_fields": [], "renamed_fields": [], "added_rules": ["block_signatory_us_pep"], "removed_rules": [], "i18n_delta": { "added": ["error.signatoryUs"], "removed": [] } }}Topic : vitakyc.{env}.form.published. Partitionné par tenantId. Rétention 30 jours en plus du WORM persistant côté DB.
7. Intégration CPS (cf ADR-026)
Section intitulée « 7. Intégration CPS (cf ADR-026) »| Action Form Designer | Effet sur CPS | Effet runtime |
|---|---|---|
Ajout d’un nouveau champ avec cpsPath = "client.x" (path inexistant) | profile-schema-svc.declare(client.x, source="form:FORM_X@v2.7", required=field.required) | aucun consommateur immédiat |
| Modification d’un libellé / hint (i18n) | aucun | SDK Web prend en charge la nouvelle PATCH |
Renommer un cpsPath (ex client.firstName → client.givenName) | REFUSÉ (I9 + ADR-026 décision secondaire). Workflow : ajouter le nouveau, deprecate l’ancien, fenêtre 90j, retirer | rupture MAJEURE |
| Suppression d’un champ référencé par policy de risque ACTIVE/SHADOW | REFUSÉ (publish 422 DeleteInUse). UI affiche : “Champ utilisé par 3 règles de risque, retirer côté Risk Matrix d’abord” | aucun changement |
| Suppression d’un champ deprecated depuis ≥ 90j sans consommateur | autorisé : profile-schema-svc.retire(client.x) | aucun |
Modification du type d’un champ (ex STRING → INTEGER) | REFUSÉ : créer un nouveau cpsPath, deprecate l’ancien | rupture |
7.1 Diagramme d’interaction
Section intitulée « 7.1 Diagramme d’interaction »8. Runtime SDK Web
Section intitulée « 8. Runtime SDK Web »8.1 Cycle de vie
Section intitulée « 8.1 Cycle de vie »8.2 État local et offline
Section intitulée « 8.2 État local et offline »- IndexedDB :
vitakyc.{tenantId}.{formId}.{version}stocke- les valeurs saisies au fil de l’eau (chiffrées localement avec
formVersionIdcomme contexte AAD) - les uploads en cours (pointers blob + checksum)
- les valeurs saisies au fil de l’eau (chiffrées localement avec
- Heartbeat connectivité : si
navigator.onLine === false, on continue à saisir, on retente upload toutes les 5 s - Reprise : au remount, IndexedDB est lu, on saute aux steps incomplètes
- Expiration : 24h sans activité → purge automatique des données locales
8.3 Protocole d’embed
Section intitulée « 8.3 Protocole d’embed »<vitakyc-form api-key="pk_live_TN-BANQUEX_xxx" form-id="FORM_KYC_INDIVIDUAL" min-version="2.7.0" locale="fr" // ou ar, en external-ref="CASE-42" on-complete="window.vitaHandle" on-error="window.vitaError"></vitakyc-form>
<script src="https://cdn.vitakyc.io/sdk/v1/vitakyc-sdk.js" crossorigin defer></script>Événements émis :
| Événement | Payload |
|---|---|
vitakyc:ready | { formId, version, locale } |
vitakyc:stepChange | { from: stepId, to: stepId } |
vitakyc:fieldChange | { fieldId, hasError } |
vitakyc:complete | { submissionId, externalRef, caseId } |
vitakyc:blocked | { ruleId, message } |
vitakyc:error | { code, recoverable: bool } |
8.4 RTL arabe — implémentation
Section intitulée « 8.4 RTL arabe — implémentation »<html dir="rtl">détecté ou forcé vialocale="ar".- CSS logique partout :
margin-inline-start,padding-inline-end,border-inline-start-color,text-align: start. Aucunleft/rightcodé en dur. - Icônes directionnelles : sprite SVG avec variante
*-rtlchargée conditionnelle. Flèches de progression inversées. - Polices :
IBM Plex Sans Arabicchargée pourlang=ar, fallbackNoto Sans Arabic. Mesurefont-sizeajustée +10 % pour matcher la hauteur d’œil arabe. - Tests d’écran : Storybook avec snapshot RTL pour les 18 écrans runtime + 24 écrans Designer (cf §10 mockups UX).
8.5 A11y (WCAG 2.1 AA)
Section intitulée « 8.5 A11y (WCAG 2.1 AA) »- Tab order = ordre DOM, focus ring custom 2 px couleur d’accent tenant (overridable via CSS variables
--vita-focus-color). - Tous les
aria-labelledby/aria-describedbycalculés depuisfield.accessibility. - Erreurs en
aria-live="polite", succès enaria-live="assertive". - Test screen-reader : NVDA (Win), VoiceOver (iOS, macOS), TalkBack (Android).
- Contrast checker intégré au Designer côté preview.
9. API REST
Section intitulée « 9. API REST »9.1 Admin (Designer back-office)
Section intitulée « 9.1 Admin (Designer back-office) »| Méthode | Endpoint | Description | Auth |
|---|---|---|---|
GET | /v1/forms | Liste des forms du tenant | bearer admin |
GET | /v1/forms/:id | Métadonnées + versions | bearer admin |
GET | /v1/forms/:id/draft | Récupère le draft courant | bearer admin |
PUT | /v1/forms/:id/draft | Sauvegarde un draft (auto-save 5 s) | bearer admin |
POST | /v1/forms/:id/preview | Évalue le draft sans publier (renvoie Resolution sur submission test) | bearer admin |
POST | /v1/forms/:id/validate | Validation publish dry-run (I9–I17) | bearer admin |
POST | /v1/forms/:id/publish | Publie le draft (signature dual-control intégrée) | bearer admin + compliance |
POST | /v1/forms/:id/activate | Bascule la version active | bearer admin |
GET | /v1/forms/:id/versions/:v | Récupère une version publiée (pour audit) | bearer admin |
GET | /v1/forms/:id/versions/:v/diff | Diff structurel avec une autre version | bearer admin |
9.2 Runtime (SDK Web)
Section intitulée « 9.2 Runtime (SDK Web) »| Méthode | Endpoint | Description | Auth |
|---|---|---|---|
GET | /v1/forms/:id/active?minVersion=2.7.0 | Form actif respectant constraint, ETag pour cache | API key tenant |
POST | /v1/submissions | Soumission complète (chiffrée) | API key + signed body |
POST | /v1/submissions/:id/files | Upload fichier ou capture biométrique | API key + signed body |
GET | /v1/i18n/:locale/catalog?formVersion=... | Catalogue i18n (pour preload mobile) | API key |
9.3 OpenAPI
Section intitulée « 9.3 OpenAPI »Spec complète : voir /api/openapi/ section Form Designer. Contract tests en CI sur deux runtimes (Kotlin Springdoc + TS Pact).
10. Performance et capacité
Section intitulée « 10. Performance et capacité »| Metric | Cible | Mesure de référence |
|---|---|---|
| Taille SDK Web prod (gzip) | ≤ 60 KB | mesuré CI (rollup-plugin-analyzer) |
| Cold start render step 1 (mobile 3G) | ≤ 1.5 s p95 | Lighthouse perf budget |
| Évaluation 50 règles | ≤ 5 ms p99 | benchmark JMH (JVM) + Vitest bench (TS) |
| Validate publish (I9–I17) | ≤ 200 ms p95 | metric prometheus form_publish_validate_seconds |
| Publish complet (incl. signature + Kafka) | ≤ 1 s p95 | metric form_publish_total_seconds |
| Capacité publish | ≥ 100 req/min/tenant | load test k6 |
| Submission size payload chiffré | ≤ 256 KB médian, 2 MB p99 | metric form_submission_bytes |
11. Sécurité
Section intitulée « 11. Sécurité »- Signature dual-control Ed25519 sur le hash canonique : un admin seul ne peut pas publier (cf ADR-006).
- Signatures vérifiées côté SDK Web via la public key du tenant pinned dans le bundle (rotation gérée comme le cert pinning mobile, cf ADR-024).
- Chiffrement enveloppe des
Submission.valuescôté serveur : DEK per-submission, KEK per-tenant, KMS Vault. PII chiffrée before storage. - Rate limit publish : 5 req/min par admin, 20/min par tenant.
- CSP strict côté SDK Web Shadow DOM :
default-src 'self' https://cdn.vitakyc.io. Pas d’unsafe-inline. - Anti-tampering local : valeurs IndexedDB chiffrées avec dérivation HKDF basée sur
(tenantId, formVersionId, sessionId). - Audit trail : chaque publish + activation tracé en append-only
form_activation_log. Conservé 10 ans. - OWASP MASVS / ASVS : niveau L2 minimum pour le SDK Web embed.
12. Plan de migration MVP → V1
Section intitulée « 12. Plan de migration MVP → V1 »| Item | MVP (V0) | V1 (S+12) |
|---|---|---|
Boucles repeat (sous-formulaires dynamiques) | non | oui |
| Lookup AML inline pendant la saisie | non | oui (event-driven) |
| A/B testing publié simultanément | non | oui (split par cookie tenant) |
| Webhooks par événement de champ | non | oui |
| Form designer multi-formulaire (parcours composé) | non | oui |
| Templates marketplace tenant-to-tenant | non | oui (publication à un catalogue commun) |
Tout ajout V1 doit produire un nouvel ADR car il modifie l’AST.
13. Checklist go-live
Section intitulée « 13. Checklist go-live »- DSL Kotlin compilé sur 3 templates pré-livrés (banque retail MENA, fintech wallet, crédit conso)
- PublishValidator passe I9–I17 sur 80 cas golden
- Évaluateur isomorphe Kotlin/TS — drift = 0 sur le corpus golden 80 cas
- SDK Web prod ≤ 60 KB gzip
- CSP strict actif, sig vérifiée client
- CPS sync end-to-end testé : ajout/suppression/deprecation traversent jusqu’à l’autocomplete Risk Matrix
- Kafka
form.publishedconsommé par CPS et risk-matrix-svc - PostgreSQL append-only contrainte effective (pas d’UPDATE/DELETE possible)
- Signatures Ed25519 dual-control fonctionnelles avec KMS Vault
- WCAG 2.1 AA testé sur 5 templates
- RTL arabe testé sur Designer + Runtime
- Pilote tenant tunisien : 1 form publié, 100 soumissions reçues, 0 incident
- Runbook on-call pour
form-runtime-svc(incidents publish, drift Kafka, Vault offline)
14. Références
Section intitulée « 14. Références »- ADR-007, ADR-026, ADR-027
- Maquettes UX Form Designer
- Client Profile Schema
- POC Form Designer — implémentation Kotlin de référence
- Risk Engine — partage le DSL de prédicats
- Maquettes app workflow 3 — éditeur Designer
- Standards : RFC 8785 (JSON Canonicalization), WCAG 2.1 AA, OWASP ASVS
Document de spec moteur Form Designer — version 1.0 (2026-04-25). Mises à jour bloquantes nécessitent un ADR.