Aller au contenu

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 :

  1. Le modèle interne (FormDefinition, StepDef, FieldDef, Rule, Action, Predicate)
  2. La représentation JSON canonique versionnée et signable
  3. Le DSL Kotlin côté serveur (designer + validation + tests + génération PDF récap)
  4. L’évaluateur isomorphe Kotlin/TypeScript et son protocole de golden tests
  5. Le pipeline de publication (validation invariants, signature dual-control, propagation Kafka)
  6. L’intégration CPS (déclaration des variables, refus suppression sous référence, events)
  7. Le runtime SDK Web (chargement, évaluation, état, persistence offline, RTL arabe)
  8. Les API REST publiques (admin) et privées (SDK)

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.


// 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/TS
type 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) »
#InvariantStadeErreur
I1formId non vide, format [A-Z][A-Z0-9_]{2,63}DSL buildInvalidFormId
I2version est un SemVer valideDSL buildInvalidVersion
I3Tous les FieldId du form sont uniquesDSL buildDuplicateFieldId
I4Tous les StepId sont uniquesDSL buildDuplicateStepId
I5Tous les RuleId sont uniquesDSL buildDuplicateRuleId
I6Au moins une stepDSL buildEmptyForm
I7Chaque step a au moins un field, sauf step purement informationnelle marquée info=trueDSL buildEmptyStep
I8Chaque field non internal_only a un cpsPathDSL buildMissingCpsPath
I9Le cpsPath existe dans le CPS du tenant ou sera déclaré (event sortant)PublishUnknownCpsPath
I10Toute I18nKey référencée existe dans i18nPublishMissingI18nKey
I11Pour chaque locale activée tenant, chaque I18nEntry a la cléPublishMissingTranslation
I12Predicate.field réfère un FieldId existant ou un CpsPath valideDSL buildUnknownReference
I13Action.target réfère un FieldId/StepId existantDSL buildUnknownTarget
I14Au moins une step contient un field type CONSENT ou la dernière step est consent.stepIdPublishMissingConsent
I15Si type SIGNATURE requis pour le segment, présent dans le formPublishMissingSignature
I16Pas de cycle dans les règles visibleIf (DAG sur les steps)PublishRuleCycle
I17Les enumCatalog réfèrent des catalogues existants (BCT_PROFESSION_CODES, …)PublishUnknownEnumCatalog
I18Hash SHA-256 calculé après normalisation canonique JSONPublishautocalculé
I19Au moins 2 signatures Ed25519 distinctes (dual-control) sur l’envelope publishPublishInsufficientSignatures

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.


  • Encodage UTF-8 NFC, sans BOM
  • Indentation 2 espaces, \n sé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, null autorisé seulement aux endroits prévus par le schéma
  • Nombres sérialisés sans zéros superflus (1, pas 1.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.

{
"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"
}
]
}
NiveauExemplesEffet 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’erreurnon-rupture : SDK Web suit ^MAJOR.MINOR automatiquement
PATCH (2.7.0 → 2.7.1)correction libellé, hint, branding, regex assouplienon-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.


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.kt
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"
}
}

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")
}

// 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 équivalentes
export 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[];
}
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 resolution

Pure : pas d’I/O, pas d’aléa, pas d’horloge implicite (now est dans Submission). Conséquence : deterministic, memoizable, testable.

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


InvariantImplémentationRejet
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 refus422 UnknownCpsPath
I10 — toute I18nKey référencée existeWalk de tous les I18nKey cités dans steps/fields/rules vs form.i18n.keys422 MissingI18nKey
I11 — chaque entry a les langues activéesPour chaque locale de form.locales, vérifier que chaque entry a la traduction non vide422 MissingTranslation
I14 — consentement présentform.consent.stepId réfère une step existante avec un field type CONSENT422 MissingConsent
I15 — signature requise présenteSi le segment tenant requiert SIGNATURE (config tenant), au moins un field type SIGNATURE dans le form422 MissingSignature
I16 — pas de cycleDAG topologique sur step.visibleIf et rule.action.skipStep422 RuleCycle
I17 — enums connusChaque enumCatalog référencé existe dans enum-catalog-svc422 UnknownEnumCatalog
I19 — dual-control2 signatures Ed25519 distinctes, keyIds différents, rôles différents (admin + compliance recommandé)422 InsufficientSignatures
-- 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 prod
REVOKE 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).

{
"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.


Action Form DesignerEffet sur CPSEffet 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)aucunSDK Web prend en charge la nouvelle PATCH
Renommer un cpsPath (ex client.firstNameclient.givenName)REFUSÉ (I9 + ADR-026 décision secondaire). Workflow : ajouter le nouveau, deprecate l’ancien, fenêtre 90j, retirerrupture MAJEURE
Suppression d’un champ référencé par policy de risque ACTIVE/SHADOWREFUSÉ (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 consommateurautorisé : profile-schema-svc.retire(client.x)aucun
Modification du type d’un champ (ex STRING → INTEGER)REFUSÉ : créer un nouveau cpsPath, deprecate l’ancienrupture

  • IndexedDB : vitakyc.{tenantId}.{formId}.{version} stocke
    • les valeurs saisies au fil de l’eau (chiffrées localement avec formVersionId comme contexte AAD)
    • les uploads en cours (pointers blob + checksum)
  • 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
<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énementPayload
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 }
  • <html dir="rtl"> détecté ou forcé via locale="ar".
  • CSS logique partout : margin-inline-start, padding-inline-end, border-inline-start-color, text-align: start. Aucun left/right codé en dur.
  • Icônes directionnelles : sprite SVG avec variante *-rtl chargée conditionnelle. Flèches de progression inversées.
  • Polices : IBM Plex Sans Arabic chargée pour lang=ar, fallback Noto Sans Arabic. Mesure font-size ajusté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).
  • 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-describedby calculés depuis field.accessibility.
  • Erreurs en aria-live="polite", succès en aria-live="assertive".
  • Test screen-reader : NVDA (Win), VoiceOver (iOS, macOS), TalkBack (Android).
  • Contrast checker intégré au Designer côté preview.

MéthodeEndpointDescriptionAuth
GET/v1/formsListe des forms du tenantbearer admin
GET/v1/forms/:idMétadonnées + versionsbearer admin
GET/v1/forms/:id/draftRécupère le draft courantbearer admin
PUT/v1/forms/:id/draftSauvegarde 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/validateValidation publish dry-run (I9–I17)bearer admin
POST/v1/forms/:id/publishPublie le draft (signature dual-control intégrée)bearer admin + compliance
POST/v1/forms/:id/activateBascule la version activebearer admin
GET/v1/forms/:id/versions/:vRécupère une version publiée (pour audit)bearer admin
GET/v1/forms/:id/versions/:v/diffDiff structurel avec une autre versionbearer admin
MéthodeEndpointDescriptionAuth
GET/v1/forms/:id/active?minVersion=2.7.0Form actif respectant constraint, ETag pour cacheAPI key tenant
POST/v1/submissionsSoumission complète (chiffrée)API key + signed body
POST/v1/submissions/:id/filesUpload fichier ou capture biométriqueAPI key + signed body
GET/v1/i18n/:locale/catalog?formVersion=...Catalogue i18n (pour preload mobile)API key

Spec complète : voir /api/openapi/ section Form Designer. Contract tests en CI sur deux runtimes (Kotlin Springdoc + TS Pact).


MetricCibleMesure de référence
Taille SDK Web prod (gzip)≤ 60 KBmesuré CI (rollup-plugin-analyzer)
Cold start render step 1 (mobile 3G)≤ 1.5 s p95Lighthouse perf budget
Évaluation 50 règles≤ 5 ms p99benchmark JMH (JVM) + Vitest bench (TS)
Validate publish (I9–I17)≤ 200 ms p95metric prometheus form_publish_validate_seconds
Publish complet (incl. signature + Kafka)≤ 1 s p95metric form_publish_total_seconds
Capacité publish≥ 100 req/min/tenantload test k6
Submission size payload chiffré≤ 256 KB médian, 2 MB p99metric form_submission_bytes

  • 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.values cô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.

ItemMVP (V0)V1 (S+12)
Boucles repeat (sous-formulaires dynamiques)nonoui
Lookup AML inline pendant la saisienonoui (event-driven)
A/B testing publié simultanémentnonoui (split par cookie tenant)
Webhooks par événement de champnonoui
Form designer multi-formulaire (parcours composé)nonoui
Templates marketplace tenant-to-tenantnonoui (publication à un catalogue commun)

Tout ajout V1 doit produire un nouvel ADR car il modifie l’AST.


  • 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.published consommé 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)


Document de spec moteur Form Designer — version 1.0 (2026-04-25). Mises à jour bloquantes nécessitent un ADR.