Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
210 changes: 210 additions & 0 deletions ADR/ADR051-add-support-for-more-comparisons-to-conditions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
# ADRxxx: Add support for more comparisons to conditions

Date: 2026-06-11

## Status

Accepted

## Context

> the facts behind the need to make the decision

We are currently adding support for multiple branches and multiple routing conditions for each question to GOV.UK Forms. As part of this we also want to allow multiple exit pages per selection question, and multiple selection options for a selection question to go to the same exit page.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about when different questions have routing conditions to the "same" exit page?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current design precludes that; exit pages are associated with one and only one question, and can only be routed to from that question.


While the current structure for form documents allows for multiple conditions per question, it only allows for a condition to be triggered by one possible selection option of a selection question:

```
# example of a form with a question with multiple conditions, in the current form document schema
# some properties omitted for brevity

{
"name": "A long form with branches",
"steps": [
{
"id": "JJqRJCYs",
"data": {
"hint_text": null,
"answer_type": "selection",
"is_optional": false,
"page_heading": null,
"is_repeatable": false,
"question_text": "Where will you be studying?",
"answer_settings": {
"only_one_option": "true",
"selection_options": [
{
"name": "England",
"value": "England"
},
{
"name": "Wales",
"value": "Wales"
},
{
"name": "Northern Ireland",
"value": "Northern Ireland"
},
{
"name": "Scotland",
"value": "Scotland"
}
]
},
"guidance_markdown": null
},
"type": "question",
"position": 3,
"next_step_id": "WHoH7UAZ",
"routing_conditions": [
{
...,
"skip_to_end": false,
"answer_value": "England",
"goto_page_id": "9GRmRKY4",
"check_page_id": "JJqRJCYs",
"routing_page_id": "JJqRJCYs",
"exit_page_heading": null,
"validation_errors": [],
"exit_page_markdown": null
},
{
...,
"skip_to_end": false,
"answer_value": "Wales",
"goto_page_id": "9GRmRKY4",
"check_page_id": "JJqRJCYs",
"routing_page_id": "JJqRJCYs",
"exit_page_heading": null,
"validation_errors": [],
"exit_page_markdown": null
},
{
...,
"skip_to_end": false,
"answer_value": "Northern Ireland",
"goto_page_id": "9GRmRKY4",
"check_page_id": "JJqRJCYs",
"routing_page_id": "JJqRJCYs",
"exit_page_heading": null,
"validation_errors": [],
"exit_page_markdown": null
}
]
},
...
],
...
}
```

This limitation is a particular problem for exit pages, where we want to avoid duplicating exit page content.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The proposed solution is really about enabling more complex routing logic (multi-value comparisons), rather than the problem it's motivated by: deduplicating exit page content.

These are separate axes. The "in" operator dedupes conditions, but the content still lives on the condition. so the moment you want the same exit page from a different question, you're duplicating heading + markdown again.

The core issue is that exit pages aren't normalised into a separate entity.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that having exit pages as a separate entity would be another way of solving this problem, and would in some ways would be preferable. That was one of the other options we considered. Unfortunately, it's a lot more work!

I explored how to migrate exit page content from the conditions model to its own model safely, it would take at least 6 steps, without changing the form document schema (which we should probably do as well). We could do it with less steps with an hour's downtime, but I don't think it would be much quicker.

It also doesn't help that at this point the design for exit pages has not yet been through usability testing, and the design for routing which motivates the design for exit pages is also only just now going through usability testing, so whatever we do could end up being unnecessary work if the result of the usability testing is that we need to make (further) large changes to the design.

The proposed solution is an attempt to find a way to meet the constraints of the designs in a pragmatic way. As you say, it doesn't help with the case where multiple questions go to the same exit page, but as currently the design doesn't allow that, that is not an issue. That extra wrinkle from the design also complicates the case for splitting out the conditions I think; because it's not clear to me now where they should sit in the model or the form document schema.

I also think the extra flexibility would be helpful generally. However, it's definitely a trade-off. It would be nice to have the time to split out conditions properly. We'd need to have a clear steer that the approach in this ADR is not what we want to do.


## Decision

After exploring different options, we have decided to extend conditions so that one condition can be triggered by multiple different possible selection options for a selection question.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you talk more about the different options you explored?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a bunch of notes in this Google Doc: https://docs.google.com/document/d/1lm1vOcpevxeBJzBc8IDXXouSwBe2q6cjkNX-GWJGRGM/edit?pli=1&tab=t.yuz2c21e5ys4#heading=h.wjfjy9chxzgu

I can add an outline of what was considered if that's helpful? Or would more detail be better?


We have decided to do this by expanding the form document schema for conditions so that `answer_value` can be a JSON object which describes a comparison:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overloading answer_value makes it polymorphic. Despite the backwards-compatible framing, it's effectively a breaking change, a consumer might get an object rather than an expected string.

Have we considered introducing a separate attribute for these new comparisons? (Which would be additive, rather than breaking.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, that's a fair point, I hadn't considered the point about consumer expecting a string. I think though since our code doesn't have any expectation about the type of the data in answer_value, it just does a blind comparison with what the answer from the user data is, this isn't in practice a breaking change?

I did consider adding a new attribute, but I thought that was itself also a breaking change, because currently answer_value === null means "always follow this route", but with an additional attribute that would no longer be the case, it would depend on the value of the other attribute.

So I was coming at it from the point of view of our existing code: it would do the wrong thing if it got a form document where there was an extra attribute for multiple answer values, whereas if the form document had something in answer_value which was not a string it would just ignore it and not route (which is still the wrong thing, but less bad than always following the route I think).


```
# example of a form with a question with a condition that triggers on multiple answer values, in the proposed form document schema
# some properties omitted for brevity

{
"name": "A long form with branches",
"steps": [
{
"id": "JJqRJCYs",
"data": {
"hint_text": null,
"answer_type": "selection",
"is_optional": false,
"page_heading": null,
"is_repeatable": false,
"question_text": "Where will you be studying?",
"answer_settings": {
"only_one_option": "true",
"selection_options": [
{
"name": "England",
"value": "England"
},
{
"name": "Wales",
"value": "Wales"
},
{
"name": "Northern Ireland",
"value": "Northern Ireland"
},
{
"name": "Scotland",
"value": "Scotland"
}
]
},
"guidance_markdown": null
},
"type": "question",
"position": 3,
"next_step_id": "WHoH7UAZ",
"routing_conditions": [
{
...,
"skip_to_end": false,
"goto_page_id": "9GRmRKY4",
"check_page_id": "JJqRJCYs",
"routing_page_id": "JJqRJCYs",
"exit_page_heading": null,
"exit_page_markdown": null,
"validation_errors": [],
"answer_value": {
"operator": "in",
"values": ["England", "Scotland", "Wales"],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What specifically do the "values" reference? Is the option selection "name" (e.g. the id) or the actual value? (I assume we want the "name" - as we want to be agnostic to the language being used (e.g. welsh or English)

@lfdebrux lfdebrux Jun 16, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's the "value" of the selection option we want, the value is language independent, and is intended to be used for routing. See govuk-forms/forms-admin@c7ce55b, govuk-forms/forms-runner#1800.

}
}
]
},
...
],
...
}
```


The structure chosen to describe the comparison looks generically like this:

```
{
"answer_value": {
"operator": ..., # string
... # other propertie as required for the operator
}
}
```

This can in future support arbitraty operators, but for this ADR we define only one operator, "in", which checks whether the answer is included in the array `values`.

```
"answer_value": {
"operator": "in",
"values": [..., ...], # array of strings
}
```

As well as being forwards-compatible with future possible comparisons, this change is backwards-compatible with the existing form document schema.

Alongside `answer_value: nil`, and `answer_value: ... # String`, adding `answer_value: { operator: "in", values: ... }` gives us three different possible comparisons (any, is, and in). We could consider in a future ADR introducing a backwards-incompatible change to the ADR to rationalise all these comparisons to use the same structure, if we wished.

## Consequences

Positive consequences:
- We can allow multiple selection options from one selection question to go to the same exit page
- We can reduce the size of form documents with selection questions that have multiple conditions routing from and going to the same question
- The form document schema changes are both backwards- and forwards-compatible

Negative consequences:
- The code for creating and updating conditions in forms-admin may be more complex
- There is ambiguity in the chosen schema, there are now multiple ways to represent the same condition

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We now have multiple ways of expressing the same logic i.e. answer_type: "example" is equivalent to answer_type: { operator: in, values: ["example"] } - which is confusing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that is messy, I agree. I'm not sure how big a problem it is though, what are your thoughts?


Additionally, as part of implementing this change, we will either change the column `answer_value` in the `conditions` table in the forms-admin database to be a `jsonb` type, or add a new `answer_value_json` column to that table, to hold the comparison JSON.
Loading