Design note · for review
Conditional forms
A required Journey Step sometimes needs a different Form based on data collected earlier. This note defines how the backend selects that Form, how the Config Studio authors the relationship, and how country-specific submissions keep their own data shapes.
? The problem#
A customer journey can reach a required step whose Form depends on information collected earlier. The step remains required. Only its Form changes.
Address
Kenya, Uganda, Rwanda, DRC, and international addresses need different fields.
Document capture
A national identity document needs front and back capture. A passport needs only front capture.
A branch is the wrong model. A branch adds or skips a collection of Journey Steps. A Conditional Form selects one Form for a Journey Step that every customer must complete.
✓ Settled decisions#
- The backend selects the Form. A client does not evaluate conditions.
- A Journey Step and a Form have separate identities.
- A Journey Step has one Default Form and can have many Conditional Forms.
- Conditional Forms are configured on the Journey Step that uses them.
- Each Conditional Form has its own condition.
- A Conditional Form does not create another Journey Step.
- Form creation and Form editing remain separate from journey authoring.
- The client receives the resolved Form, not the rules that selected it.
- The design uses Conditional Form, not form variant. The service already uses variant for channel-specific journey configurations.
The existing formKey and formVersion properties remain the Default
Form. Existing journey configurations therefore remain valid.
≡ Terms#
Journey Step
A stable stage such as address or ekyc. Its identity does not change when the selected Form changes.
Form
A versioned schema and user-interface definition such as address_ke or ekyc_passport.
Default Form
The Form that applies when no Conditional Form matches.
Conditional Form
A Form linked to a Journey Step with a condition based on earlier validated journey data.
{ } Journey configuration#
The proposed configuration keeps the current Default Form properties and adds
conditionalForms to the Journey Step.
{
"id": "ekyc",
"type": "FORM",
"formKey": "ekyc_national_id",
"formVersion": 1,
"conditionalForms": [
{
"formKey": "ekyc_passport",
"formVersion": 1,
"condition": {
"all": [
{
"sourceStep": "basic_info",
"field": "/documentType",
"operator": "equals",
"value": "PASPO"
}
]
}
}
]
}
| Property | Meaning |
|---|---|
id | The Journey Step identity. |
formKey and formVersion | The Default Form. |
conditionalForms | The Forms that can replace the Default Form for this Journey Step. |
sourceStep | The earlier Journey Step that supplied the data. |
field | A JSON Pointer into the source step's normalized submission. |
operator and value | The comparison that selects the Conditional Form. |
No selection outcome is needed. The Conditional Form already states the condition and the Form to select. An outcome such as SELECT_FORM would repeat that information.
⌂ Address example#
Assume that residence collects countryOfResidence before the
customer reaches address.
{
"id": "address",
"type": "FORM",
"formKey": "address_international",
"formVersion": 1,
"conditionalForms": [
{
"formKey": "address_ke",
"formVersion": 2,
"condition": {
"all": [{
"sourceStep": "residence",
"field": "/countryOfResidence",
"operator": "equals",
"value": "KE"
}]
}
},
{
"formKey": "address_ug",
"formVersion": 1,
"condition": {
"all": [{
"sourceStep": "residence",
"field": "/countryOfResidence",
"operator": "equals",
"value": "UG"
}]
}
},
{
"formKey": "address_rw",
"formVersion": 3,
"condition": {
"all": [{
"sourceStep": "residence",
"field": "/countryOfResidence",
"operator": "equals",
"value": "RW"
}]
}
}
]
}
The international Form is the Default Form. A DRC Form and other country Forms follow the same pattern when their address structures need separate definitions.
| Form | Example fields |
|---|---|
address_ke | county, subCounty, town, and village |
address_ug | district, town, street, and village |
address_rw | province, district, sector, cell, and village |
address_international | Address lines, a locality, and a postal code when required |
Each Form defines and validates its own data shape.
→ Backend resolution#
FormEngine owns the journey configuration and the individual Forms. Onboarding owns the running journey and its submitted data. Form selection therefore belongs in Onboarding.
- FormEngine parses and stores the Journey Step with its Conditional Forms.
- Onboarding copies the Journey Steps into a new journey snapshot.
- Onboarding validates and normalizes the current submission.
- Existing journey logic resolves the next Journey Step.
- Onboarding loads the validated submissions referenced by that step's conditions.
- Onboarding selects the Default Form or one Conditional Form.
- FormEngine resolves the selected Form and applies country and channel patches.
- Onboarding stores the resolved Form in
OnboardingFormBinding. - Onboarding returns the resolved Form to the client.
Why FormEngine does not make the runtime choice
FormEngine can load and patch a known Form. It does not own customer submissions or the state of a running onboarding journey.
The current Form resolver caches by Form key, Form version, country, and channel. The cache key does not include customer answers. Onboarding must select the Form key before FormEngine uses that cache.
Why Onboarding stores the selected Form
OnboardingFormBinding already records the Journey Step, the Form key, the Form
version, and the resolved schemas. Submission validation uses this binding.
Resume and retry requests return the bound Form. They do not evaluate the conditions again.
! Resolution rules#
- If the Journey Step has no Conditional Forms, use the Default Form.
- If no condition matches, use the Default Form.
- If exactly one condition matches, use that Conditional Form.
- If more than one condition matches, stop with a configuration error.
- If a required source submission is missing, stop with a resolution error.
- If a rule points to a missing required field, stop with a resolution error.
- Do not use rule order as an implicit priority.
Source-step rules
A condition can use only data that exists before the target Journey Step starts.
The configuration validator rejects these references:
- The target Journey Step itself.
- A later Journey Step.
- A Journey Step that is not guaranteed to run before the target step.
- An unknown Journey Step.
The first Journey Step cannot use Conditional Forms based on submitted journey data. A later design could allow trusted initial context as another source.
Condition language
The backend condition model already supports equals, not_equals,
in, not_in, greater_than,
less_than, is_empty, and is_not_empty.
The first version can support one all or any group. A JSON Pointer
must start with /. The source Journey Step must come before the target Journey
Step, not at the same sequence number.
▶ Preview and publication#
The preview needs previous-step submissions before it can resolve a Conditional Form. The rule tester shows the source values, matching Forms, the selected Form, the reason for a Default Form, and resolution errors.
The API Response Mock returns only the resolved Form for the selected scenario. It does not expose rules to client teams.
A published change bundle contains:
- The journey configuration with Conditional Form links and conditions.
- Each new Form referenced by the change.
- The patches and translations for those Forms.
The bundle does not create extra Journey Steps.
▦ Country-specific data shapes#
A Conditional Form does not map its fields into one universal address object.
The stable boundary is the metadata around the submission:
{
"stepId": "address",
"formKey": "address_ke",
"formVersion": 2,
"data": {
"county": "Nairobi",
"subCounty": "Westlands",
"town": "Nairobi",
"village": "Spring Valley"
}
}
A Ugandan address keeps its own shape:
{
"stepId": "address",
"formKey": "address_ug",
"formVersion": 1,
"data": {
"district": "Kampala",
"town": "Kampala",
"street": "Kira Road",
"village": "Kamwokya"
}
}
The common guarantees are limited:
- The data belongs to the
addressJourney Step. - The Form key and Form version identify the data shape.
- The backend validated the data against that Form version.
- Downstream code can determine how to interpret the data.
Do not create a universal sparse address. A model with every country field would accumulate nullable properties and allow combinations that do not describe a valid address in any country.
⇄ Downstream boundary#
Current limitation
StepSubmission already stores StepId, FormKey,
FormVersion, and NormalizedData. The current workflow aggregation
keeps only StepId and NormalizedData.
The retail workflow mapper also assumes that every address has
countryOfResidence, state, city,
zipCode, and address. That fixed mapping cannot preserve the
country-specific fields in this note.
Proposed boundary
ResolvedStepSubmission
StepId
FormKey
FormVersion
Data
A destination can then choose one of these approaches:
- Accept the country-specific data without transformation.
- Select a country-specific mapper by Form key.
- Apply a Form-specific mapping configured for that destination.
- Convert the data into a formatted address when the destination accepts only a string.
If a destination supports only one fixed address type, the integration defines how to translate each country shape. Conditional Form selection and destination mapping remain separate concerns.
◇ Where Conditional Forms fit#
Use a Conditional Form
- A required OCR step needs a different capture Form for each document type.
- A required address step needs a different Form for the customer's country of residence.
- A required tax-details step needs different questions after an earlier tax classification.
- A required consent step needs a distinct Form for an earlier customer category.
Use a branch when the Journey Steps change
A branch adds or skips one or more Journey Steps. A Conditional Form does not change journey structure.
Use a Form patch for deployment scope
A country or channel patch suits a difference determined by deployment context. A Conditional Form suits a difference determined by a customer answer.
Use schema conditions inside one Form
A Conditional Form resolves before the customer enters the Journey Step. It does not change while the customer edits the Form. Use schema-driven field conditions for immediate changes inside one Form.
Keep destination mapping separate
Form selection answers which Form applies. Destination mapping answers how another system consumes the submitted data.
⚒ Backend change area#
FormEngine
StepJsonDtoacceptsconditionalForms.JourneyStepParserparses each Conditional Form and condition.Stepstores an empty-by-default Conditional Form collection.GetStepResponsecarries the collection across the internal module boundary.- The parser rejects invalid source steps, pointers, duplicate links, and incomplete rules.
Journey Steps already use an nvarchar(max) JSON column. An optional collection
does not need another relational column. A persistence test must confirm that old Journey
Step JSON produces an empty collection.
Onboarding
JourneyStepstores the Conditional Forms copied from FormEngine.StartOnboardingcopies them into the journey snapshot.- Step advancement loads the submissions referenced by the next step's conditions.
- A resolver selects the Form reference before
BindStepFormAsyncruns. OnboardingFormBindingstores the resolved Form under the Journey Step identity.
The current query is not enough. Step advancement now loads only data referenced by branches and exit rules. It must also load Conditional Form source steps and retain the current normalized submission before the transaction commits it.
Condition evaluation
Onboarding has separate evaluation code for branches and exit rules. A shared evaluator would keep JSON Pointer and operator behavior consistent. That refactor needs regression tests for existing branches and exit rules.
Client and workflow contracts
The current-step response can remain unchanged. The public journey-configuration response should not expose Conditional Form rules without a separate client need.
Workflow aggregation must retain the selected Form key and Form version when a mapper needs to interpret different data shapes.
The in-service OCR path appears to tolerate a missing back document. The external workflow contract still needs a test with a front-only passport submission.
✓ Configuration and runtime validation#
The Studio and the backend both validate the configuration. The backend remains authoritative.
| When | Reject or report |
|---|---|
| Configuration | A Conditional Form without a condition, or a condition without a Form reference. |
| Configuration | An unknown Form version, duplicate link, or exact duplicate condition. |
| Configuration | An unknown or unreachable source Journey Step. |
| Configuration | A malformed JSON Pointer, unsupported operator, or missing comparison value. |
| Runtime | A missing source submission or required source field. |
| Runtime | More than one matching Conditional Form. |
| Runtime | A selected Form that FormEngine cannot resolve. |
The backend logs the journey version, Journey Step, selected Form key, selected Form version, and whether the Default Form applied. Logs do not contain customer values.
↦ Versioning and rollout#
The optional conditionalForms collection keeps existing Journey Steps static.
Existing configurations need no rewrite.
New onboarding journeys use the newly published journey version. Existing onboarding journeys keep the configuration and Form bindings that they captured at start.
- Add the optional backend model, parser validation, and persistence tests.
- Add runtime selection and binding without exposing rules to clients.
- Pilot passport and national identity document capture.
- Add Conditional Form authoring and rule testing to the Config Studio.
- Add address Forms after the workflow boundary can retain or map country-specific data.
? Open questions#
- Should
conditionalFormsandconditionbe the final JSON property names? - Should the first release allow Conditional Forms on branch steps?
- How does the validator prove that a source Journey Step always runs before a target inside a branch?
- What happens when a source Journey Step also has Conditional Forms and the selected source Form lacks the configured pointer?
- Should the first release reject a pointer that is not present in every possible source Form?
- Can publication detect overlapping conditions, or must runtime detection remain?
- Does the external workflow accept a front-only passport submission?
- What address structure can each downstream workflow accept?
- Does destination field mapping need a separate configuration model?
- Should trusted initial context become a condition source for the first Journey Step later?
The central design does not depend on these answers. A Journey Step remains stable, the backend selects one linked Form, and the selected Form keeps its own validated data shape.