Results Remediation
1. Goals & constraints
What we want from remediation:
- Fix author mistakes (wrong key, bad item) after delivery.
- Keep snapshots immutable – never rewrite what the candidate saw.
- Keep an audit trail – show what changed, who did it, when, and why.
- Let scores and outcomes change safely – including any future Programme / requirement checks.
- Be incremental – we can start simple (item-level corrections) and expand if needed.
Given your current design (immutable version_snapshot + results_service scoring), the cleanest pattern is:
Keep submissions and snapshots immutable as records of what happened, but add a correction ledger and a score history, and make “current score” always reflect the latest rules.
2. Data model
2.1 Correction batches (the “what and why”)
A batch groups one or more corrections with a reason and actor.
Table: result_correction_batches
id UUID PKtenant_id UUIDcreated_by UUID– admin user IDreason TEXT– “Q3 had wrong answer key”, etc.created_at TIMESTAMPTZ- Optional:
evaluation_version_id UUID– if it’s scoped to a single test
A batch is your “remediation event”: “On this date, we fixed these questions for this exam”.
2.2 Correction rules (the “how to treat this item now”)
Each rule describes what to do with a specific broken item.
Table: result_corrections
-
id UUID PK -
batch_id UUID REFERENCES result_correction_batches(id) -
evaluation_version_id UUID– target eval version -
question_version_id UUID– the specific item -
correction_type TEXT– start with a small enum-like set:drop_item– item contributes 0 to max and score.mark_correct– treat all answers as fully correct.replace_key– usenew_keyinstead of the original for scoring.
-
new_key JSONB NULL– only used whencorrection_type = 'replace_key'.- E.g.
{ "qtype": "mcq_single", "correctIds": ["c2"] }
- E.g.
-
note TEXT– optional explanation -
applied_at TIMESTAMPTZ
These rules are not applied by mutating snapshots. They are used by the scoring engine as an overlay when re-evaluating affected submissions.
2.3 Score history (the “before and after”)
We want to:
- know what the original score/outcome were,
- know what the new ones are, and
- still have
submissionshold the current truth for day-to-day reporting.
Table: submission_score_versions (or submission_score_history)
id UUID PKsubmission_id UUID REFERENCES submissions(id)version_no INT– 1 for original, 2+ for each remediationsource TEXT–initial|remediationbatch_id UUID NULL– link toresult_correction_batcheswhen source =remediationscore NUMERICmax_score NUMERICoutcome_code TEXTcreated_at TIMESTAMPTZ
And a small addition to submissions (optional but helpful):
latest_score_version INT– pointer to the latest row insubmission_score_versions.- Or you can infer “latest” by max(version_no) per submission, but a pointer is cheaper.
Behaviour:
-
On the first scoring (today’s behaviour), you:
- insert a
submission_score_versionsrow withsource='initial',version_no=1, - write the same score/outcome into
submissionsfor fast queries.
- insert a
-
On rescoring, you:
- add a new row with
source='remediation',version_no = previous + 1,batch_id=..., - update
submissions.score,submissions.max_score,submissions.outcome_codeto the new values and bumplatest_score_version.
- add a new row with
This way:
- The history is in
submission_score_versions. - The current truth is in
submissions. - You never touch the
version_snapshot.
3. Correction logic (what a “rescore” actually does)
At a high level, rescoring for a batch would:
-
Identify all affected submissions.
-
For each submission:
- Load its
version_snapshotand recorded answers. - Apply the correction rules for any impacted questions.
- Recalculate item scores + total score + outcome.
- Append a new entry to
submission_score_versions. - Update
submissionswith the new score/outcome.
- Load its
3.1 Finding affected submissions
In practice, a batch may specify one or more (evaluation_version_id, question_version_id) pairs.
To find affected submissions, you can scope to:
-
submissions.evaluation_version_id = <evaluation_version_id>AND -
either:
- look up answers in
submission_itemsbyquestion_version_id, or - (if needed) inspect
version_snapshot.questions– butsubmission_itemswill usually be cheaper.
- look up answers in
You don’t need to rewrite anything in those rows; you just reuse them as input to scoring.
3.2 How the correction types behave
-
drop_item- Treat the item as if it has 0 max_score and 0 score for everyone.
- Effectively normalises the test as “total score from the remaining items”.
- This usually means changing the denominator:
max_score_new = max_score_old - item_max_score.
-
mark_correct- For everyone who attempted the question, give full credit regardless of their selected response.
score_item = item_max_scorefor all.- This keeps the denominator the same but pushes everyone up on that item.
-
replace_key- Treat
new_keyas the authoritative marking key. - Re-score the item for each candidate using the new key.
- This can push some scores up and others down.
- Treat
Because you have full snapshots and answers, the scoring engine can apply these rules deterministically.
4. Interaction with Programmes (future pillar)
Programmes will, at some point, decide:
-
“Has this candidate met Requirement A?” based on:
- current scores/outcomes for certain submissions.
If submissions.score and submissions.outcome_code always reflect the latest scoring version, then Programmes don’t need to know about corrections explicitly. They will:
- read the current score/outcome, and
- evaluate requirements accordingly.
What you may want later, once Programmes exist:
-
A small job or service that, when you apply a correction batch, also:
- re-evaluates
program_progressfor any enrolments tied to affected submissions, - updates requirement statuses (e.g., someone who just crossed the threshold).
- re-evaluates
The important bit: do not bake “original score” into Programmes tables. Always derive satisfaction from the current submission score/outcome, so remediation naturally flows through.
5. Phased implementation suggestion
Here’s how I’d phase this so it stays manageable:
Phase 1 – Minimal but safe remediation engine
-
Add:
result_correction_batchesresult_correctionssubmission_score_versions
-
Implement:
-
Recording of initial scores into
submission_score_versionswhen a submission is first created. -
A backend-only “apply batch” function that:
- takes a batch ID,
- finds affected submissions,
- re-runs scoring with correction rules,
- writes history + updates
submissions.
-
Usage at this stage could be:
- via a CLI or admin-only API; no fancy UI needed yet.
Phase 2 – Operator experience
Later, when you have more UI capacity:
-
Add simple admin tooling:
- list batches and their status,
- see how many submissions were affected,
- see before/after scores for a sample candidate,
- show a flag “rescored on <date> due to correction <reason>” in result views.
Phase 3 – Programme awareness (once Programmes exist)
When Programmes are in place:
-
Add a small service (or job) that, when a correction batch runs:
- identifies any
program_enrolmentswhose requirements depend on affected submissions, - re-evaluates requirement satisfaction using the current scores,
- updates
program_progressaccordingly.
- identifies any
That way:
- A mis-keyed question in a certification path can be corrected and will retroactively fix the Programme status too.