Check Performance Data - 16-19 results enquiry — report an incorrect grade
AB#296648. A school reports that one of its 16-19 students has been given the wrong exam grade, from choosing the enquiry type through to a reference number on screen and by email.
Related tickets: AB#296999 (results data), AB#297004 (student selection), AB#297130 (grade reference data), AB#297013.
The journey
| Page id | Type | What it asks |
|---|---|---|
check-late-results |
Content |
Guidance: check your second late results file first. Entered by the controller, not the flow's firstPageId — see Late results guidance. |
cohort-scope |
Question / Radio |
Does the incorrect grade affect the whole cohort? Branches the journey. |
cohort-count |
Question / FreeText |
How many students (cohort branch only). Validated by the WholeNumber format validator. |
select-student-cohort |
PupilSearch |
One student as an example (cohort branch). Lists only students who hold results — see Only students who hold results. |
select-student-single |
PupilSearch |
The affected student (single branch). Same restriction. |
select-result |
ResultSearch |
Which of the student's results is wrong. |
grade-details |
ResultDetails |
Shows the chosen result; asks for the revised grade. |
additional-info |
Question / TextArea |
Optional comments, 250 characters. |
Then the shared journey summary (Journey/Summary.cshtml, enquiry branch) and
Journey/EnquiryConfirmation.cshtml.
Flow config: src/DfE.CheckPerformanceData.Web/Data/QuestionFlows/IncorrectGrade_Post16.json, resolved
by the usual {WhatToChange}_{CheckingWindowType} key. Page and question ids are a serialization
contract — they are written into session state and into the persisted journey blob, so renaming one
after merge orphans stored enquiries. IncorrectGradeFlowTests pins them.
Getting in
The check-your-student-data page's "What would you like to do?" radios gain a third option for 16-19
windows: Report an issue with an exam result, routed to ResultIssueController. The issue page
renders only the Incorrect grade option; the two others on the Figma screen belong to sibling
tickets, and posting their values is rejected as unanswered rather than starting a journey with no flow
behind it.
Late results guidance
Exam results arrive in batches: main + non-included + late results 1 in October, late results 2 in November, revised in February, retention in March. Nearly all incorrect grades correct themselves in the November batch, so a school reporting one in October may be doing work that batch was about to do for them.
Decision (BA, 2026-08-17): the guidance informs, it never blocks. When the school holds no
16to19_LR2 row, ResultIssueController routes into check-late-results; when it does, straight to
cohort-scope. The option itself is always selectable. A Figma frame showing the option greyed out
with "Incorrect grade option will be available after releasing second late results" was considered
and not chosen — it contradicts the ticket's own acceptance criteria.
Because the flow's firstPageId is cohort-scope, the controller seeds QuestionHistory with
check-late-results when that is the entry point. Without it the journey engine's out-of-sequence
guard bounces the user straight past the guidance and the AC silently never happens.
Availability is derived, never configured: ILateResultsAvailability asks
IStudentResultsClient.AnyForSourceAsync(..., "16to19_LR2"). That is the only place the service
decides what "the second late results file has landed" means.
Data seams
Student results — IStudentResultsClient
Container {windowId}, blob results-enquiry/data/{laestab}_results.json. One merged array per
school across all six supplier files, each row stamped with its source tag.
Written by the results-enquiry checking exercise's own ingress run (#324): one dataset slot per
source file, each stamping its SOURCE tag onto every record it contributes, all merged into one
file per school in a single run. SeedStudentResults still writes the same blob in development, so
a developer needs no supplier files. Only the main file is required to validate the exercise — the
late, revised and retention files are optional slots, because they land weeks apart and one may
never land, and each run rewrites the school's whole file from the slots that are filled. The supplier CSVs must carry a LAESTAB column — that is what
splits one file into one blob per school — and a file without one fails the run by name.
The results-enquiry/ prefix is deliberate: per consequence #2 of docs/16-19-window-model.md each
checking exercise owns its own blob prefix, so when ingress becomes per-exercise no migration is
needed and one exercise's sweep cannot destroy another's output. Pupil-data checking keeps its bare
data/ prefix. Every path segment lives in ResultsEnquiryBlobPaths.
Source tags (ResultsFileTags, verbatim from AB#296999 — a data contract with ingestion):
| Constant | Value |
|---|---|
Post16Main |
16to19_MAIN |
Post16LateResults1 |
16to19_LR1 |
Post16LateResults2 |
16to19_LR2 |
Post16Revised |
16to19_Revised |
Post16Retention |
16to19_Retention |
Ks4Main |
KS4_MAIN |
Ks4LateResults1 |
KS4_LR1 |
Ks4LateResults2 |
KS4_LR2 |
Ks4Revised |
KS4_Revised |
Reads are cached 30 minutes per results:{windowId}:{laestab}, matching the pupil cache so a school's
results and pupils go stale together. A missing blob reads as empty; malformed JSON throws so a corrupt
file surfaces. Numeric-looking values are tolerated unquoted, because CSV-to-JSON converters routinely
emit them that way (TolerantStringJsonConverter).
A result is identified by a composite key QAN|SESSION|SOURCE, not by QAN — a student can hold the
same qualification across sessions and across source files.
Grade reference — IGradeReferenceClient
Container rules-config, blob grade-reference.json, beside rules.json because it is the same kind
of thing: slow-moving reference data from another team, shared by every window, self-seeded from a
bundled copy. Cached 5 minutes; a missing blob reads as an empty lookup rather than throwing.
Seeded from src/DfE.CheckPerformanceData.Web/Data/GradeReference/grade-reference.json,
seed-if-missing (If-None-Match: *) so an environment that has had the real AODC export loaded is
never clobbered by a redeploy of an older bundled copy.
The checked-in seed holds the three AB#297130 examples plus the dev QANs. The IB Diploma's 93-grade
scale is derived from the ticket (44 pass: 24B/24D … 45B/45D; 49 fail: 00F–45F, R, U,
X) and is what gives the tests their 24F-vs-24D case.
Only students who hold results
Both PupilSearch pages set "requireResults": true. A student with no result has no grade to
correct, so they are not a candidate, and offering them leads only to a dead end.
How it is wired, layer by layer:
-
IStudentResultsClient.GetStudentIdsWithResultsAsync(windowId, laestab)returns the school's CYPMD ids, case-insensitively, from the already cached results file — an autocomplete keystroke costs no download. -
CheckYourPupilDataService.GetPupilSuggestionsAsync(..., requireResults)resolves that set only when asked, and hands it to the repository. Every other journey passes null and searches the whole roll. -
CheckYourPupilDataRepository.SearchPupilsAsync(..., cypmdIdAllowList)applies it before the ten-suggestion cap. Filtering after the cap would drop the one student who holds results whenever ten who do not sort ahead of them.
Persistence never learns what a result is — it receives a set of ids.
The restriction is a search restriction, never a permission. It only ever narrows a search that is
already scoped to the signed-in school's own file, so a request that forges or omits
requireResults=true reaches nothing new.
Because it hides students, the pages say so. The subheading ends "You can only search for
students who have results", and the autocomplete's no-match text becomes "No students found with
results" rather than the component's default "No results found" — otherwise a school cannot tell a
typo from a student who holds nothing. Copy on both is FLAGGED for content sign-off.
select-result keeps its own empty state for the cases the restriction cannot cover — back
navigation, a stale session, or a results file that changes mid-journey. Rather than an autocomplete
that can never answer, it states that we hold no results for the student and links back to the
student search. It renders instead of the control and the Continue button, which could only ever
fail validation.
Revised-grade rules
Server-authoritative, in this order (JourneyValidationService.ValidateGradeSelect):
- Unanswered →
Select the revised grade - Equal to the result's current grade →
The revised grade must be different from the current grade - Not a grade the QAN offers → treated as unanswered (fail closed against a forged post)
- QAN absent from the reference data → the picker is empty, the page says
We cannot list grades for this qualification yet, a warning is logged, and validation can never pass
Comparison is ordinal and case-sensitive. The IB Diploma is why: 24F is a fail and 24D a pass,
so 24F → 24D is a real enquiry, and any normalising comparison risks either rejecting it or
accepting a no-op.
Progressive enhancement
Both pickers are server-rendered GOV.UK <select>s that accessible-autocomplete upgrades in place via
enhanceSelectElement, so both pages work with JavaScript off. The result options are rendered
server-side rather than fetched: a student holds a handful of results, so there is nothing to gain from
a round-trip, and a fetch-only control would be unusable without script. The grade picker is enhanced
because some qualifications award 93 grades.
/results/suggestions (ResultSuggestionsController) exists and is tested but is not used by the
result page as a consequence. It is scoped to the session's selected student — no pupil id is ever
taken from the query string. Decide whether to keep it for the sibling "Review exam results" tickets or
remove it.
Submission
RequestService.SubmitResultsEnquiryAsync makes the same two writes a pupil change request does:
- a
ChangeRequestsrow —RequestType.ResultsEnquiry,AmendmentType = IncorrectGrade,Status = SubmittedUnCommitted, descriptionResults enquiry - Incorrect grade - the journey JSON via
IRequestStateBlobClient
Both enum columns are HasConversion<string>() capped at 20 characters; ResultsEnquiry (14) and
IncorrectGrade (14) fit, which is why no migration was needed.
Reference format: CYPMD_16to19_RE_{7 hex} (GenerateEnquiryReference). The RE segment lets support
staff tell an enquiry from an amendment when a school reads one out. The confirmation mockup's
3014023_RE10005 was confirmed illustrative.
No duplicate check — the spec allows several enquiries for the same student and result.
Two places the duplicate rule had to be taught about enquiries
RequestRepository enforces one submitted request per pupil per window, to stop two competing
amendments to the same record. An enquiry changes no pupil data, so it neither raises that conflict
nor counts as one. ResultsEnquiry rows are therefore excluded from both:
-
UpsertAsync's hard block (and the check is skipped entirely for an incoming enquiry) -
CheckForConflictAsync, which drives the pupil-search duplicate warning
The two must stay in step, or a user is warned about something that then submits fine — or worse, the reverse. Without these exclusions, reporting a wrong grade for a student blocked every later amendment for them, with an error naming an unrelated request.
Choosing a student discards only what came after
PupilSearchPost used to clear every answer when a primary pupil was selected — correct for the
amendment journeys, where the pupil page is first. The enquiry journey asks about the cohort before
the student, so it now keeps answers from pages earlier in the history and discards only those after,
along with SelectedResult (a result belongs to one student). Wiping them lost the cohort scope and
count, and the summary silently presented a cohort-wide enquiry as a single-student one.
Downstream processing — a separate story
Decision (BA, 2026-08-17): an enquiry drops into ChangeRequests and saves its journey JSON, and
is not enqueued. It is ultimately bound for Zendesk, but how it gets there is a separate ticket.
Two consequences, both deliberate and both commented in code:
-
SubmitResultsEnquiryAsyncdoes not enqueue. When the dispatch story lands, the enqueue belongs there and nowhere else. -
AdminRequestsService.ProcessCloseWindowEventskipsIncorrectGradejourneys. That replay builds a pupil-amendment ticket, and an enquiry's QAN, session, current and revised grade have no place in that shape. Replaying one would create a malformed ticket and flip the row toSubmittedCommitted, so the real dispatch could never find it again. -
QuestionFlowOutcomeKeyAlignmentTestslistsIncorrectGradeinFlowPrefixesThatDoNotRouteToTheRulesEngineand asserts it has no outcome key, so nobody can quietly bind it to rules-engine routing. That list going empty is the signal every flow routes.
Confirmation email
NotificationType.ResultsEnquirySubmitted, dispatched by
RequestNotificationService.NotifyResultsEnquirySubmittedAsync through the existing
INotificationDispatcher → NotificationSender pipeline. Personalisation is ref number and
email address. It carries no deadline (an enquiry is not something the school must come back and
finish) and goes to the submitter only (nothing is being asked of the rest of the school).
The template does not exist yet. Notify:ResultsEnquirySubmittedTemplateId is empty in every
environment config. NotifyService now logs a warning and sends nothing when a template id is blank,
rather than throwing out of its template-id switch — so the journey completes without it, but no
enquiry email is sent until the template is created and the id configured.
Analytics
Event catalogue additions — see also docs/bigquery-analytics.md.
| Event | Fields | Notes |
|---|---|---|
results_enquiry_started |
enquiry_type, checking_window_type, late_results_guidance_shown
|
Emitted by ResultIssueController. The guidance flag answers the question behind the interstitial: are we stopping enquiries that did not need raising? |
results_enquiry_submitted |
enquiry_type, cohort_wide, checking_window_type, reference_number (hidden)
|
Emitted by JourneyController on submission. |
PII rules: the reference number is always Hidden. No grade, QAN, student name, session or free text
ever leaves as a plain field — a grade paired with a school and a date is identifying, and the comments
box is free text by definition. cohort_wide is a boolean rather than the count for the same reason.
Validation failures flow through the existing validation_error event; GradeSelect codes as
selection_invalid alongside Radio and Autocomplete.
Local development
SeedStudentResults writes results for Kingsmead (860/4070) in the seeded Post16 window: mixed
16to19_MAIN / 16to19_LR1 tags, one qualification held twice in different sessions, and no
16to19_LR2 rows so the interstitial is on the happy path.
Three students (500001–500003) carry the Figma screens' own qualification fixtures. The CYPMD ids
are the ones SeedPupilData actually generates — a result keyed to Figma's own id would belong to no
selectable student and dead-end the journey. E2E drives 500001 by name, so those three rows are
pinned by SeedStudentResultsTests.
The rest is generated across both populations (every third included student, every fifth non-included), giving roughly a quarter of the school. That is deliberate on both sides: with the search restricted to students who hold results, three students leave a manual tester unable to exercise a common-surname search or the ten-suggestion cap, while seeding everyone would hide both the restriction and the empty state behind data that never exercises them. Generated qualifications come from the seeded grade reference, so the revised-grade picker can always list grades.
docker compose --profile web --profile database --profile storage up -d --build
# then, as an impersonated editor:
# /CheckYourPupilData/6c2e1f4a-9b7d-4e38-8a15-3d9c2b4e7f01
Alignment with the 16-19 window model
Per docs/16-19-window-model.md, without building any of the checking-exercise model:
-
Naming is
ResultsEnquiry(plural) throughout, matchingCheckingExerciseType. -
Journey identity uses Option A:
WhatToChangeCheckingExerciseMap.CheckingExerciseForis the one lookup from aWhatToChangeto its checking exercise. The futureIsSessionReadygating consumes it; nothing else may hardcode the mapping. -
Blob layout is born exercise-scoped (
results-enquiry/data/). -
The entry radio shows for any open 16-19 window.
// PARKEDcomments mark where visibility moves toICheckingExerciseService.OpenCheckingExercises.
Not built here: CheckingExercise entity and migrations, ICheckingExerciseService, read-only page
states, dataset reparenting, per-exercise ingress, draft-across-boundary rules.
Deliberately out of scope
The "Review exam results" / Results / Late-results tab pages and CSV/ZIP downloads (entry-point ticket); the six-file ingestion pipeline itself (FACT tickets — the portal side of it, the admin upload and ingress run that fill these blobs, is #324); missing-qualification and result-does-not-belong-to-student flows (sibling tickets); drafts (decided against); duplicate-enquiry blocking (the spec allows multiples).
Still open
| Item | Owner |
|---|---|
Full AODC export (Dynamic form QAN list 2026 v1.xlsx, SharePoint) → replace the seeded grade-reference.json
|
AODC team |
GOV.UK Notify template + Notify:ResultsEnquirySubmittedTemplateId — no email sends without it
|
Ops / content |
| Copy sign-off: the must-differ message; "We cannot list grades for this qualification yet"; the issue page's expander body (never captured in Figma); the result label's appended session | Content designer |
Whether to keep or delete /results/suggestions
|
Dev team |
Breadcrumb: the designs show Check your student data - 16 to 19 and result enquiry, but no journey view in the service renders a breadcrumb. Worth doing across the whole 16-19 journey at once rather than on one page |
Design / dev |
PupilSearch.cshtml is still JavaScript-dependent (pre-existing). The same enhanceSelectElement approach used here would fix it |
Dev team |