Check Performance Data - Question flow format
The reference for the JSON files that drive the change request journeys. request-journey.md describes the journey from start to end. question-flow-deployment.md records why the files ship in the image.
Overview
The journey feature (pupil change requests) is driven by JSON config files that define branching question flows.
-
Config files live in
Web/Data/QuestionFlows/{WhatToChange}_{CheckingWindowType}.json(e.g.Remove_KS4June.json). -
They ship in the release image and are served from it in every environment.
IQuestionFlowConfigSource(Application) is implemented byFileSystemQuestionFlowClient(Infrastructure), reading{ContentRootPath}/Data/QuestionFlows/.IQuestionFlowServicecaches the result inIMemoryCachewithNeverRemovepriority. The interface is read-only — nothing writes a config at runtime. -
Two flows may share a page id.
Remove_KS2.jsonreuses the KS4 June page ids on purpose. Each flow file holds its own rules for those pages. A test that looks a page up by id must check every page with that id, or name the file; never callSingleon the id alone. -
There is no blob container and no seeding step. Configs used to be read from the
question-flowsblob container, which Terraform provisions empty and which only a Development-gated seeder ever filled, so a config change did not reach QA/preproduction/production by deploying.docs/question-flow-deployment.mdrecords the decision and what a storage-backed source would have to do if one is ever wanted again. The container is still declared in Terraform, unused, on purpose.
Page types
| Type | JSON "type"
|
Notes |
|---|---|---|
Question |
"Question" (default) |
One or more questions rendered in sequence. Handled by JourneyController.Page / PagePost. |
Content |
"Content" |
Informational page with no questions. Skipped in summary and RequestDocument answers. |
EvidenceUpload |
"EvidenceUpload" |
Specialised question page for file uploads. Rendered by EvidenceUpload.cshtml. |
PupilSearch |
"PupilSearch" |
Full-page accessible-autocomplete pupil selector. Handled by JourneyController.PupilSearchPage / PupilSearchPost. Skipped in summary rows and RequestDocument answers. |
PupilSearch page fields
{
"id": "select-pupil",
"type": "PupilSearch",
"title": "Which pupil do you want to remove?",
"pupilFilter": "Included",
"pupilKey": "primary",
"nextPageId": "reason",
"validationFailure": "Select the name of the pupil you want to remove"
}
| Field | Required | Values | Notes |
|---|---|---|---|
pupilFilter |
yes |
"Included" / "All" / "Includable" / "NotIncludable"
|
Included limits results to pupils stamped INCLUDED; All returns every pupil for the school; Includable returns pupils stamped INCLUDABLE — the Include journey's search, which on 16-19 adds the included file's P_INCL 502 students to the non-included file (#711); NotIncludable is the rest, which the Include journey's "already included" guards look a name up in |
pupilKey |
yes |
"primary" / "match"
|
primary saves to RequestState.SelectedPupil* and generates the reference number; match saves to RequestState.MatchedPupil*
|
requireResults |
no |
true / false (default) |
Limits the search to students the school holds a 16-19 result for. Independent of pupilFilter, which selects by inclusion status. Set on the results-enquiry student search — there is no grade to correct for a student with no result. The page must say so in its subheading, because the restriction silently hides students |
nextPageId |
no | page id string | Absent means redirect to Summary after selection |
validationFailure |
no | string (supports {pupilName}) |
Error shown when no pupil is submitted. Falls back to "Enter the name of the pupil"
|
Suggestions endpoint: GET /pupils/suggestions?windowId={id}&query={q}&filter=Included|All|Includable|NotIncludable&excludePupilId={guid}&requireResults=true (served by PupilSuggestionsController). The match pupil page automatically passes the primary pupil's ID as excludePupilId so it cannot be selected twice. requireResults is sent only when the page config sets it; it is a search restriction, never a permission — it can reach no pupil outside the signed-in school's own file. When it is on, the autocomplete's no-match text changes to "No students found with results" so a school can tell a typo from a student who holds nothing.
Navigation: JourneyController.Page (GET) transparently dispatches to PupilSearchPage when the requested page has Type == PupilSearch, so all navigation (summary redirects, GetNextPageId, navigation guard) works uniformly.
History: completing a PupilSearch page appends its id to QuestionHistory exactly like a question page — the existing navigation guard mechanism works without modification.
Question types
| Type | JSON "type"
|
Notes |
|---|---|---|
Radio |
"Radio" |
Single-choice options; options can set nextPageId to branch the flow, and visibleWhen to gate visibility (see below) |
FreeText |
"FreeText" |
Single-line text input |
TextArea |
"TextArea" |
Multi-line text; optional charLimit
|
Date |
"Date" |
Day/month/year fields; stored as DateAnswer. Every format failure (blank or part-filled, year not 4 digits, not a real date) shows the question's validationFailure + " in the format dd mm yyyy" (#544), so every Date question must set a validationFailure that does not spell out the format itself (QuestionFlowValidatorAlignmentTests pins this) |
FileUpload |
"FileUpload" |
PDF upload; max 6 pages total across all uploads |
Checkbox |
"Checkbox" |
Multi-select list. Options are shaped exactly like a Radio's (and visibleWhen filters them the same way), but an option's nextPageId is ignored — several boxes may be ticked, so only the page's own nextPageId decides. The answer is QuestionAnswer.SelectedValues (the ticked values, TextValue stays null); "at least one must be ticked" is the ordinary required rule, so optional: true still allows an empty list. Displayed by CheckboxAnswerDisplay.Join (labels, config order) with JoinValues as the request document's raw value |
Autocomplete |
"Autocomplete" |
Accessible-autocomplete dropdown backed by a server suggestions endpoint. Requires a "dataSource" field (e.g. "countries") that maps to the URL prefix /{dataSource}/suggestions. The selected display name is stored in QuestionAnswer.TextValue; the selected code is submitted as {fieldName}_code but not currently stored in the answer model. |
Adding a new Autocomplete data source: create an I{Source}Service / I{Source}Repository pair in Application, a repository implementation in Persistence querying the relevant table, and a controller in Web with a GET /{source}/suggestions?query= endpoint returning [{id, label}]. Register both in the respective DependencyManager.cs.
Conditions (visibleWhen, optionalWhen, requireAtLeastOneWhen)
For a plain-English guide with examples of common changes, see journey-rules.md.
Three fields take a condition list:
| Field | On | When every entry holds | When an entry is undecided |
|---|---|---|---|
visibleWhen |
an option or a question | it is shown | it is hidden |
optionalWhen |
a question | it is optional | it stays mandatory |
requireAtLeastOneWhen |
a page with requireAtLeastOne
|
the rule applies | the rule applies |
The value is one entry or an array of entries. Every entry must hold (AND). An entry has one of these forms:
-
A fact test, for example
{ "fact": "school.isIndependent", "is": false }. It holds when the registeredIJourneyConditionwith thatNamegives theisvalue. An unregistered fact is undecided. A fact is something that the answers do not hold. Always write"is", also fortrue. A string is not a condition: the flow file fails to load, and the error shows the object to write. -
An answer test, for example
{ "answer": "first-language", "in": ["other", "not-known"] }. It holds when the answer to that question is one of the values. A question with no answer, or a blank answer, is undecided. Do not write a class to test an answer. -
An any-of test, for example
{ "anyOf": [ <entry>, <entry> ] }. It holds when one or more of its entries hold. It needs two or more entries. When no entry holds and one entry is undecided, it is undecided.
These are the facts:
| Fact | True when |
|---|---|
pupil.isAddBack |
The selected pupil's inclusion flag (Pincl) is 403, "added back". No pupil, or no Pincl, gives false. |
school.isIndependent |
The school's GIAS establishment type id is "11" (Other Independent School). Type 10 is not included. |
school.isOnNotOnRollList |
The school's LAESTAB is in the not-on-roll college list (see below). |
qualification.hasSyllabusCodes |
The selected qualification has one or more syllabus codes. |
Do not write a class for "not X" or for "X or Y". Use "is": false or anyOf in the flow file.
"optionalWhen": [
{ "answer": "reason", "in": ["english-not-first-language"] },
{ "answer": "first-language", "in": ["other", "believed-other", "chose-not-to-say", "not-known"] }
]
"visibleWhen": [
{ "fact": "pupil.isAddBack", "is": false },
{ "anyOf": [
{ "fact": "school.isIndependent", "is": true },
{ "fact": "school.isOnNotOnRollList", "is": true }
] }
]
VisibleWhenJsonConverter reads the three fields into JourneyConditionRef lists. It throws on a malformed object, so a bad flow file fails when it loads. JourneyConditionEvaluator evaluates a list, and its caller says what an undecided entry gives. QuestionFlowValidatorAlignmentTests checks that each fact has a class, and that each answer test names a question in the same flow with one chosen answer (not a Checkbox), and only values from its options.
The rest of this section describes visibleWhen on options. A Radio option with visibleWhen is only rendered when its conditions hold. This lets a single config show different options to different users (e.g. the "Not on roll" removal reason only appears for independent schools).
-
IJourneyCondition(Application) —Name+bool Evaluate(JourneyConditionContext ctx). Implementations are pure logic and live inApplication/Journey/Conditions/(no external dependencies), registered asIJourneyConditionin the ApplicationDependencyManager. The facts are in the table above.SchoolIsOnNotOnRollListCondition(AB#304119) reads the FE colleges inWeb/Data/NotOnRollColleges/not-on-roll-colleges.json, matched on LAESTAB. On startup the web app copies that file to therules-configcontainer (replacing the blob when it differs) andNotOnRollCollegeListStoreholds the list in memory, refreshed every five minutes, because conditions are synchronous. A condition may take constructor dependencies;QuestionFlowValidatorAlignmentTestsbuilds each one with substitutes. -
JourneyConditionContextcarries theRequestState(Journey) and aJourneyUserContext(User) — a neutral snapshot of the signed-in user assembled in the Web layer fromICurrentUserService, so Application never touchesHttpContext.OrganisationTypeIdflows from the DfE Sign-in get-organisations$.type.idfield →organisation_type_idclaim (added inClaimsEnrichmentService). -
IOptionVisibilityService.GetVisibleOptions(question, ctx)filters a question's options preserving order. Options with novisibleWhenare always shown; an undecided entry hides the option (fail closed). The controller computesVisibleOptionsperRadioquestion forBuildPageVm, and_Radio.cshtmliterates those rather than the raw config options.
Page-level validation (requireAtLeastOne)
A page may set "requireAtLeastOne": true (e.g. an EvidenceUpload page where either a file or a text explanation suffices). When set, the page validates that at least one of its questions is answered even if the individual questions are optional. IJourneyValidationService.ValidateRequireAtLeastOne returns a RequireAtLeastOneResult (summary lead-in message + per-question field errors) when the rule is violated; the controller maps these into ModelState and the error summary. Note: when a question is optional but has been filled in, its format rules (char limit, real date) are still enforced.
Named format validators (validator)
A text question may set "validator": "<Name>" to enforce a reusable format rule on its answer once it is non-empty (e.g. a DfE number box uses "validator": "DfeNumber"). This mirrors the visibleWhen/IJourneyCondition pattern:
-
IFormatValidator(Application,Application/Journey/Validators/) —Name+bool IsValid(string value)+FailureMessage. Pure logic, no external dependencies, registered asIFormatValidatorin the ApplicationDependencyManager. Current validator:DfeNumberFormatValidator(Name = "DfeNumber", matchesnnn/nnnnor seven-digitnnnnnnn; validation only — it does not normalise between the two forms). -
JourneyValidationServicetakesIEnumerable<IFormatValidator>and runs the named rule insideValidateAnswer, after the required/type-specific checks pass and only when the value is non-empty (emptiness is the required rule's concern). An unregistered validator name fails open (the check is skipped) so a config typo cannot block every submission —QuestionFlowValidatorAlignmentTestsguards that every referenced name has an implementation. -
Adding a new validator: implement
IFormatValidatorinApplication/Journey/Validators/, register it in the ApplicationDependencyManager, and reference itsNamefrom a question'svalidatorfield.
Date rules (dateRules)
A Date question can have a dateRules list. Each rule compares the answer with a date. DateRuleEvaluator (Application, Application/Journey/DateRules/) runs the rules, and JourneyValidationService.ValidatePageDates calls it.
{ "id": "date-pupil-started", "type": "Date",
"dateRules": [
{ "check": "notInFuture", "message": "The date {pupilName} started at your school must be in the past" },
{ "check": "onOrAfter", "fact": "cohort.start", "message": "The date {pupilName} started at your school cannot be more than 4 years ago when the cohort began" },
{ "check": "onOrAfter", "question": "date-pupil-started-school-in-england", "message": "The date {pupilName} started at your school must be the same as or after {date} when they first started at a school in England" }
] }
Remove_KS4June.json has a full example on the english-not-first-language-details page.
Each rule has a check and a message. There are three checks:
check |
Fails when | Target |
|---|---|---|
notInFuture |
The answer is later than UK today. Today passes. | None |
onOrBefore |
The answer is later than the target. Equal dates pass. | One |
onOrAfter |
The answer is earlier than the target. Equal dates pass. | One |
A comparison (onOrBefore or onOrAfter) names exactly one target:
-
question: the id of anotherDatequestion in the same flow. The target is the answer to that question. -
fact: the name of a date fact (see the table below). -
date: a fixed date, writtenyyyy-MM-dd, for example"2023-09-01".
Order. The rules run in list order. The first rule that fails gives the message. Each question gets one message at most. Put notInFuture first, because a comparison with a wrong date can mislead the user.
Message. {pupilName} becomes the pupil name. {date} becomes the target date, written d MMMM yyyy (for example "1 September 2022").
When a rule is skipped.
- A blank or invalid answer takes part in no rule. The format rules report it.
- A comparison is skipped when its target has no value, for example when the user has not answered the target question.
- A date fact that is not registered, or that gives null, skips its rule. The evaluator logs a warning with the fact name only.
Rules between two questions. Declare a comparison between two questions on both questions. Each question has its own message. For example, date-of-birth has onOrBefore admission-date, and admission-date has onOrAfter date-of-birth. The evaluator checks only the questions on the current page. It compares them with answers from the whole journey. So when the two questions are on different pages, the error shows on the page that the user is on.
These are the date facts:
| Fact | Value |
|---|---|
cohort.start |
1 September of the school year four years back (for a 2026 window, 1 September 2022). The rules engine checks the same boundary on schoolAdmissionDate in RulesEngineWorker/seed/rules.json. If one changes, change the other. |
-
IJourneyDateFact(Application,Application/Journey/DateRules/) has aNameandDateOnly? Value(JourneyConditionContext ctx, DateOnly today). Use a date fact only for a date that the answers do not hold. The code gives the date, and the flow file holds the rule. -
Adding a new date fact: implement
IJourneyDateFactinApplication/Journey/DateRules/, register it in the ApplicationDependencyManager, add it to the table above, and reference itsNamefrom a rule'sfactfield.
A bad rule does not throw at run time. DateRuleConfigTests checks every flow file instead:
- Every
Datequestion hasnotInFutureas its first rule. - Only
Datequestions have rules. - Each rule has a message.
-
notInFuturenames no target, and a comparison names exactly one target. - Each
questiontarget is aDatequestion in the same flow. - Each comparison between two questions has its mirror rule on the other question (
onOrBeforeagainstonOrAfter). - Each fact has an implementation, and each implementation is registered in the container.