Check Performance Data - Add a pupil not found on my DfE pupil roll (simple path)
AB#297310. A school adds a pupil who is missing from their DfE pupil roll, for KS4 June and KS4 Autumn checking windows (KS2 has no Add journey). This is the simple path: no dataset matching against the roll — the soft-match story (AB#297780) is a separate ticket that will sit in front of this one.
The journey
| Page id | Type | What it asks |
|---|---|---|
learner-details |
Question (multi) / pupilFromAnswers: true
|
First name, last name, date of birth, sex, UPN (optional). These answers ARE the pupil — see The synthetic pupil. |
admission-details |
Question (multi) |
Admission date, year group, SEN status. |
evidence |
EvidenceUpload |
Optional file upload + optional free-text explanation, same shape as every other amendment journey's evidence page. |
Then the shared journey summary (Journey/Summary.cshtml) and Journey/Confirmation.cshtml —
both unchanged, reused exactly as every amendment journey uses them.
Flow configs: src/DfE.CheckPerformanceData.Web/Data/QuestionFlows/Add_KS4June.json,
Add_KS4Autumn.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 requests.
AddFlowTests pins them.
Getting in
The what-to-change page (WhatToChangeController) shows Add a pupil to data
(WhatToChange.Add) only when an Add_{CheckingWindowType}.json flow exists for the window type
(IQuestionFlowService.GetPupilDataChangesAsync). Today that is KS4June and KS4Autumn. KS2 has
no Add journey (KS2 September 2027 requirements) and Post16 has none either. The POST uses the same
rule, so a posted Add for a window with no flow file is refused.
Starting clean: the pupil-search-less reset
Confirm clears the whole per-request identity — reference number, selected pupil, matched pupil,
selected result, answers, history — whenever the chosen flow has no PageType.PupilSearch page.
This is not an Add special case, it is the invariant Add was the first flow to need. Every other
journey opens with a pupil search, and JourneyController.PupilSearchPost unconditionally
regenerates the reference and the selected pupil and nulls the matched pupil and result on every
selection — so those flows have always started clean by accident of their shape. A flow without one
inherits whatever the previous journey left in session. Two things went wrong before this reset
existed:
-
A submitted request's reference was reused.
SummaryConfirmclears every other per-request field after a successful submission but deliberately keepsReferenceNumber, because the confirmation page reads it back out of session to render.AddPupilJourney.BuildPupilthen reuses it (refreshed.ReferenceNumber ?? …, correct for re-edits within one journey), and the upsert overwrote the already-submitted row. -
An abandoned Merge journey's matched pupil surfaced on the Add summary.
JourneyViewModelBuilderandSubmittedRequestService.BuildMergeDisplaysbuild the "First/Second record to merge" rows fromMatchedPupil != null && SelectedPupil != nullalone, with no check onWhatToChange— so the Add summary replaced its "Pupil name" row with an unrelated pupil's name and CYPMD id, and that pupil's fullPupilDtowas persisted into the Add request's journey blob.
Keying the reset on the flow's shape rather than on WhatToChange.Add means the next
pupil-search-less journey inherits the guarantee instead of the bug.
The synthetic pupil
Every other amendment journey starts with a pupil-search step that resolves a real dataset pupil
into RequestState.SelectedPupil (a PupilDto, not the supplier-file PupilRecord — that shape
has no bearing on a pupil that was never in a supplier file). The Add journey has no such step: the
pupil doesn't exist in the roll yet, which is the entire point of the ticket.
Instead, AddPupilJourney.BuildPupil (Web/Controllers/Journey/AddPupilJourney.cs) mints a
PupilDto directly from the learner-details page's typed answers, and JourneyController.PagePost
stores it as SelectedPupil the moment that page is successfully posted
(MintSyntheticPupilIfNeeded, called from both PagePost save points — the normal redirect and the
fromSummary edit-from-summary branch). This is the one genuinely new mechanism the ticket needed:
every downstream consumer — summary {pupilName} templating, drafts, BuildChangeRequestData, the
amendment grid, withdraw — reads SelectedPupil exactly as it already does, unchanged.
The synthetic PupilDto.Id is a fresh Guid, stable across re-edits of the same journey
(BuildPupil reuses the existing id when one is already set) but never colliding with another
typed-in pupil. This is correct specifically because dataset matching is out of scope here — a
fresh id per journey means the one-request-per-pupil duplicate rule can never falsely conflate two
different pupils a school is adding. PupilDto.Age/Cypmd_Id have no learner-details equivalent
and are set to 0/"" — verified unread for Add, since RequestService.SubmitRequestAsync only
builds BuildRequestDocument (the reader of those fields) for non-Add submissions.
AB#297780 seam
The learner-details page's successful POST — JourneyController.PagePost, the block guarded by
page.PupilFromAnswers — is the interception point the future soft-match story will use to check
the typed name/DOB/UPN against the roll before minting a synthetic pupil. It carries a named
AB#297780 SEAM comment. No speculative branching code exists yet.
The duplicate check's Include hand-off needs an Include flow (#749)
The duplicate-check page (AB#297780) can hand a matched, non-included pupil to the Include
journey. "Include this pupil" does this for one match. "Switch to include" does it for one row
of a multiple-match list. The hand-off needs Include_{CheckingWindowType}.json. KS4 Autumn has no Include flow.
JourneyController sets DuplicateCheckViewModel.IncludeJourneyAvailable from the same list
What to change renders (GetPupilDataChangesAsync). The view renders neither button without it.
The two POST actions return to the duplicate-check page when the flow is missing. The flag is
false by default, so a page built without it offers no Include action (fail closed).
Validation
- Required-field + valid-date on every mandatory question, exactly as the ticket's validation column states.
-
Character limits: names capped at 150, UPN at 13, the evidence comments at 500 (#516 for KS4 June, #749 for KS4 Autumn)
(
Question.CharacterLimit/characterLimitin the flow JSON). This required extendingJourneyValidationService.ValidateAnswer'sCharacterLimitarm fromTextArea-only to also coverFreeText— the arm didn't exist forFreeTextbefore this ticket. -
Date rules (
dateRulesin the flow JSON, see question-flows.md): date of birth and admission date must not be later than today. Today itself is accepted. The date of birth must be on or before the admission date. The two questions are on different pages, so each declares the rule with its own message.DateRuleConfigTestschecks the rules in every flow file.
Both the character-limit extension and the future-date rules were flagged assumptions, not directly specified by the ticket's own validation column — confirm with the BA, but cheap and defensible to keep either way.
LDS bound values
These are a hard contract against the LDS_CYPMD_Data specification v2.4 the egress story reads
from. Consumed by Application/Egress/EgressRecordBuilder.Build since AB#294553.
Where the egress story will find them. Not in a RequestDocument: BuildAnswerRecord and its
RawValue/Value split only exist inside RequestService.BuildRequestDocument, which an Add
submission never reaches (no rules-engine enqueue — see below). The record is the persisted
journey blob, and it stores raw RequestState.QuestionAnswers:
-
Radio answers (sex, year group, SEN status) store the option's stable value — the LDS code —
in
QuestionAnswer.TextValue. The display label lives only in the flow config and is looked up at render time, so copy changes cannot move the code. This half is exactly as intended. -
Date answers store a
DateAnswer { Day, Month, Year }object, not an ISO string. Nothing in the Add path producesYYYY-MM-DD, so the egress story has to format the parts itself (a one-liner —DateAnswer.ToDateOnly()already hands back aDateOnlyfor a complete date). Worth knowing before that story assumes an ISO string is waiting for it.
| Field | Question id | Bound values |
|---|---|---|
| Sex | sex |
F, M, U (labelled Female / Male / Not known) |
| SEN status | sen-status |
E, K, N (EHC plan / SEN support / No recorded SEN — in that order) |
| Year group (KS4) | year-group |
10, 11
|
| First/last name |
first-name / last-name
|
Free text, ≤150 characters |
| UPN | upn |
Free text, ≤13 characters, optional |
| Date of birth / admission date |
date-of-birth / admission-date
|
Real calendar date, not in the future — persisted in the journey blob as DateAnswer { Day, Month, Year }, to be formatted to YYYY-MM-DD by the egress story |
Submission — through the Rules Engine (#536)
RequestService.SubmitRequestAsync makes the same writes every other amendment makes — a
ChangeRequests row (RequestType.Amendment, AmendmentType = WhatToChange.Add,
Status = Submitted), the saved RequestDocument and the journey JSON via IRequestStateBlobClient
— and enqueues the document to the Rules Engine. The row appears on the school's amendment grid as
Add, can be withdrawn, and gets the standard confirmation email.
An Add request used to skip the Rules Engine (PARKED AB#297310). It no longer does. It takes the
AddPupil outcome (AnswerFieldMap), which rules.json binds to one default Scrutiny rule until
Add rules are written. The RulesConsumer sets ProcessingStatus = Decided. The close sweep then
sends the saved document to Zendesk like any other decided amendment. QuestionFlowOutcomeKeyAlignmentTests
no longer lists Add as a flow that skips the Rules Engine.
Downstream is the LDS egress (LDS_CYPMD_Data specification v2.4.xlsx) — a separate story that
reads the ChangeRequests row and journey blob this ticket persists.
No new enum members, no new pages
Deliberate, per the plan's design decisions:
- No
Addentry inWhatToChangeToOutcomeKey— see above. - No new
RequestTypeenum member —RequestType.Amendment+AmendmentType.Addis the typed identity, same pattern every other amendment type uses. - No pupil-search page in the Add flows — the synthetic-pupil mechanism replaces it entirely.
- No new
PageType—learner-detailsandadmission-detailsare ordinaryQuestionpages; only thepupilFromAnswersflag is new.
Local development
docker compose up -d --build
# then, as an impersonated editor:
# /WhatToChange/f34d285b-8660-4d12-9c30-787328deaa0a (the seeded KS4 June window)
# /WhatToChange/7d2e9a14-5c38-4b6f-a0e1-3f8b6c2d4a97 (the seeded KS4 Autumn window)
Both seeded KS4 windows carry the Kingsmead roll and the Casey Carter pair (#747). The Add
journey and its duplicate check can be walked on either. AddPupilJourneyTests and
AddPupilDuplicateCheckTests run on both windows (#749).
Deliberately out of scope
Dataset matching against the roll (ticket A: "do not test for matches" — this is the whole reason AB#297780 exists as a separate story); the soft-match query/branching journeys; UPN format validation beyond the 13-character cap (the matching story owns it).
Still open
| Item | Owner |
|---|---|
Copy sign-off: the radio label "Add a pupil to data"; the sex option U labelled "Not known"; the SEN option order E→K→N; every page/question/error string in the two flow configs — none of this reached Figma Epic B1 from this environment, so it was implemented verbatim and pinned by tests |
Content designer |
The two flagged assumptions (future-date rules, character caps) — confirm with the BA; if struck, delete the JSON characterLimit fields. DateRuleConfigTests requires a notInFuture rule on every Date question, so striking the future-date rule also changes that test |
BA |
AB#297780 soft-match: the interception point is named and commented (JourneyController.PagePost, page.PupilFromAnswers), but no matching logic exists yet |
Dev team (future ticket) |