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.

Proposal · not implemented Backend resolved One step · many forms No client rule engine

? 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"
          }
        ]
      }
    }
  ]
}
PropertyMeaning
idThe Journey Step identity.
formKey and formVersionThe Default Form.
conditionalFormsThe Forms that can replace the Default Form for this Journey Step.
sourceStepThe earlier Journey Step that supplied the data.
fieldA JSON Pointer into the source step's normalized submission.
operator and valueThe 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.

FormExample fields
address_kecounty, subCounty, town, and village
address_ugdistrict, town, street, and village
address_rwprovince, district, sector, cell, and village
address_internationalAddress 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.

1 · SubmitValidate current step→ 2 · NavigateResolve next step→ 3 · EvaluateLoad referenced data→ 4 · SelectChoose one Form→ 5 · BindStore resolved Form→ 6 · ReturnSend Form to client
  1. FormEngine parses and stores the Journey Step with its Conditional Forms.
  2. Onboarding copies the Journey Steps into a new journey snapshot.
  3. Onboarding validates and normalizes the current submission.
  4. Existing journey logic resolves the next Journey Step.
  5. Onboarding loads the validated submissions referenced by that step's conditions.
  6. Onboarding selects the Default Form or one Conditional Form.
  7. FormEngine resolves the selected Form and applies country and channel patches.
  8. Onboarding stores the resolved Form in OnboardingFormBinding.
  9. 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.

✎ Authoring model#

The journey builder is the only place where an author links Conditional Forms. The Journey Step editor shows the Default Form, each Conditional Form, its condition, and any validation error.

Ways to link a Form

Link existing

Select a Form that already exists. The Studio does not copy it.

Clone Default Form

Create a separate Form from the Default Form, then open the Form editor.

Create Form

Create a Form from scratch, then open the Form editor.

After the author saves a cloned or new Form, the Studio returns to the original Journey Step and continues the pending link. Creating a Form does not create a Journey Step.

The Studio rejects the link when the same Form key and Form version already appear on the Journey Step. The author must complete the condition before the Studio commits the link.

Rule editor

InputBehavior
Source Journey StepLists only earlier Journey Steps that can supply data.
FieldProvides a schema-based picker and a manual JSON Pointer input.
OperatorLists only operators that the backend supports.
ValueUses the source field schema to choose a suitable input when possible.
Match groupChooses whether all conditions or any condition must match.

The manual input preserves JSON Pointer escaping. A property that contains / uses ~1. A property that contains ~ uses ~0.

▶ 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 address Journey 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

  • StepJsonDto accepts conditionalForms.
  • JourneyStepParser parses each Conditional Form and condition.
  • Step stores an empty-by-default Conditional Form collection.
  • GetStepResponse carries 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

  • JourneyStep stores the Conditional Forms copied from FormEngine.
  • StartOnboarding copies them into the journey snapshot.
  • Step advancement loads the submissions referenced by the next step's conditions.
  • A resolver selects the Form reference before BindStepFormAsync runs.
  • OnboardingFormBinding stores 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.

WhenReject or report
ConfigurationA Conditional Form without a condition, or a condition without a Form reference.
ConfigurationAn unknown Form version, duplicate link, or exact duplicate condition.
ConfigurationAn unknown or unreachable source Journey Step.
ConfigurationA malformed JSON Pointer, unsupported operator, or missing comparison value.
RuntimeA missing source submission or required source field.
RuntimeMore than one matching Conditional Form.
RuntimeA 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.

  1. Add the optional backend model, parser validation, and persistence tests.
  2. Add runtime selection and binding without exposing rules to clients.
  3. Pilot passport and national identity document capture.
  4. Add Conditional Form authoring and rule testing to the Config Studio.
  5. Add address Forms after the workflow boundary can retain or map country-specific data.

? Open questions#

  1. Should conditionalForms and condition be the final JSON property names?
  2. Should the first release allow Conditional Forms on branch steps?
  3. How does the validator prove that a source Journey Step always runs before a target inside a branch?
  4. What happens when a source Journey Step also has Conditional Forms and the selected source Form lacks the configured pointer?
  5. Should the first release reject a pointer that is not present in every possible source Form?
  6. Can publication detect overlapping conditions, or must runtime detection remain?
  7. Does the external workflow accept a front-only passport submission?
  8. What address structure can each downstream workflow accept?
  9. Does destination field mapping need a separate configuration model?
  10. 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.

↑ Top