Add threshold- and decision-backed pass/fail results for CourseTasks
Preferred deadline
September 1, 2026, before the redesigned JS/TS Web Dev course starts on September 6, 2026.
This result mode is needed from the beginning of the course for P0/autotasks and the first project gates, so it cannot be deferred to a later Stage 2 workflow.
Problem
RS App currently treats every CourseTask result as a numeric score:
- CourseTask has maxScore and scoreWeight but no result mode.
- TaskResult requires a numeric score, including its history records.
- CourseTaskModal requires Score and Score Weight for every task.
- WriteScoreService can only persist scores.
- score recalculation and the score table assume every TaskResult is numeric.
Some courses need projects, onboarding tasks, and formative autotasks to be genuine completion gates: the result is Passed or Failed, not an artificial number. Storing pass as 1 and fail as 0 would leak fake points into ranking, exports, prerequisites, and reports.
TaskVerification.status cannot be reused for this purpose. It currently describes verification execution (pending/completed/error/cancelled), not whether the student's task passed.
Most projects, tests, autotasks, and crosschecks can derive pass/fail from an internal percentage and a configurable threshold. Technical Screening is different: its result must come directly from the Mentor's existing selection decision and must not require or expose an artificial numeric score.
Proposed model
Add an explicit result type to CourseTask:
type CourseTaskResultType = 'score' | 'pass-fail';- Existing and newly created tasks default to score.
- A score task stores a numeric course score and uses maxScore/scoreWeight as today.
- A pass-fail task declares an evaluation mode:
thresholdordecision. - A threshold-backed pass-fail task has a required
passThresholdPercentfrom 0 to 100, for example 70%. - Each threshold-backed assessment retains quantitative evidence: either earned/maximum raw points or a submitted percentage, plus the normalized percentage and frozen threshold. RS App derives Passed or Failed server-side from that percentage and threshold.
- A decision-backed pass-fail task accepts an explicit decision from an authorized assessor and has no score, raw points, percentage, or threshold.
- Technical Screening uses the decision-backed mode: the existing Mentor decision is authoritative. A positive decision maps to Passed, a negative decision maps to Failed, and an unset/deferred decision remains No result yet.
- The internal percentage is not a numeric course score and must not enter total score, ranking, or compensating certificate points.
- “No result yet” remains represented by the absence of a TaskResult; it must not be confused with failed.
- Required/optional status and downstream certificate policy are separate concerns. Do not introduce “optional” as a third result type.
A possible persistence shape is:
type TaskResultStatus = 'passed' | 'failed';
type PassFailEvaluationMode = 'threshold' | 'decision';
type PassFailAssessment =
| {
mode: 'threshold';
earnedPoints?: number;
maximumPoints?: number;
percentage: number;
passThresholdPercent: number;
}
| {
mode: 'decision';
decision: 'passed' | 'failed';
};
class CourseTask {
resultType: 'score' | 'pass-fail';
maxScore: number | null;
scoreWeight: number | null;
passFailEvaluationMode: PassFailEvaluationMode | null;
passThresholdPercent: number | null;
}
class TaskResult {
score: number | null;
status: TaskResultStatus | null;
passFailAssessment: PassFailAssessment | null;
}The exact database representation may differ, but invalid combinations must be impossible at the service/API boundary:
| CourseTask result type | Valid TaskResult |
|---|---|
| score | numeric course score is present; status and pass-fail assessment are null |
| pass-fail / threshold | status and normalized assessment are present; numeric course score is null |
| pass-fail / decision | status and authorized assessor decision are present; score, percentage, and threshold are null |
Do not encode pass/fail as 1/0 or place the internal percentage in the numeric course-score field.
Administration
In the Course Task form, add Result type:
- Score
- Pass/fail
For Score:
- Max score is required.
- Score weight is required and behaves as it does now.
For Pass/fail:
- Select evaluation mode: Threshold or Assessor decision.
- For Threshold, pass threshold percentage is required and validated from 0 to 100. An internal maximum may be configured when the rubric/checker produces raw points; it is not presented as a course maximum score.
- For Assessor decision, threshold, score, and internal maximum are hidden/disabled and not required.
- Score weight is hidden/disabled and is not required in either pass/fail mode.
- Technical Screening can be configured as Assessor decision, removing its numeric Score field while preserving the existing Mentor decision workflow.
- The task is visibly identified as pass/fail in admin lists and details.
Changing the result type or pass threshold after results already exist must be blocked with a clear explanation. A future explicit migration tool may handle such changes; silently reinterpreting existing results is unsafe.
Result submission
Expose the result type in CourseTask DTOs and use discriminated payloads conceptually equivalent to:
type ThresholdPassFailInput =
| { earnedPoints: number; maximumPoints: number }
| { percentage: number };
type SubmittedTaskResult =
| { type: 'score'; score: number; comment?: string; githubPrUrl?: string }
| { type: 'pass-fail'; mode: 'threshold'; assessment: ThresholdPassFailInput; comment?: string; githubPrUrl?: string }
| { type: 'pass-fail'; mode: 'decision'; decision: 'passed' | 'failed'; comment?: string; githubPrUrl?: string };For threshold-backed pass/fail input, the server:
- validates the raw points or percentage;
- normalizes to a percentage from 0 to 100;
- snapshots the configured threshold;
- derives passed when percentage >= threshold, otherwise failed;
- stores the quantitative evidence and derived status without writing a numeric course score.
For decision-backed input, the server validates that the actor is authorized, stores the explicit decision and assessor, and writes the corresponding status without a numeric score or quantitative assessment. Technical Screening must use its existing Mentor decision as this input rather than asking the Mentor for a second result.
Requirements:
- Existing numeric submission endpoints and clients remain compatible for score tasks.
- Numeric course-score input for a pass-fail task is rejected.
- Pass-fail assessment input for a score task is rejected.
- Manual single and bulk submission UIs show a number input for score tasks, raw-points/percentage controls for threshold-backed tasks, and an explicit authorized decision control for decision-backed tasks.
- Technical Screening does not show or require a redundant numeric score; positive, negative, and unset/deferred Mentor decisions map to Passed, Failed, and No result yet respectively.
- CSV import/export uses explicit pass-fail fields (for example percentage, threshold, and status), never 1/0.
- Comments, author, immutable submission link, timestamps, and audit history work for both result types.
- A result update appends the submitted evidence, frozen threshold, and derived outcome to typed history rather than overwriting it invisibly.
- Any authorized administrative status override requires a reason and remains distinct from a normally derived result.
Automated verification
Keep raw verification execution separate from the final task outcome:
- TaskVerification.status continues to represent pending/completed/error/cancelled.
- A completed checker run does not automatically mean the task passed.
- For a threshold-backed pass-fail CourseTask, the checker supplies earned/maximum points or a normalized percentage.
- Decision-backed tasks do not accept checker percentages and are completed only through an authorized assessor decision.
- RS App applies the CourseTask threshold and derives the final status server-side.
- Raw checker details, assertions, points, and percentage remain available as assessment evidence.
- The final TaskResult stores the pass-fail assessment and status while leaving its numeric course score null.
Example: an autotest produces 42/50 = 84%; with a frozen threshold of 70%, the TaskResult is Passed. The 84% is retained for audit and feedback but contributes zero numeric course points.
Existing score-producing autotests continue to work unchanged for score tasks. The checker callback/API should be extended compatibly for pass-fail assessment evidence.
Crosscheck compatibility
Preserve the current crosscheck assignment and aggregation behavior, including the CourseTask's configured pair count and the existing rule that calculates the crosscheck result from pair count - 1 completed reviews.
For a threshold-backed pass/fail CourseTask:
- retain every reviewer's raw rubric points, normalized percentage, criterion comments, and existing review evidence;
- wait for the same number of completed results that the current crosscheck flow requires;
- calculate the aggregate crosscheck score/percentage exactly as the current flow does;
- normalize that aggregate to a percentage, snapshot the CourseTask threshold, and derive the final Passed/Failed status from the aggregate;
- retain the aggregate percentage and frozen threshold for audit and appeal without writing them as numeric course points;
- when an appeal or authorized correction replaces the aggregate crosscheck result, recalculate the derived status from the replacement value and the applicable frozen threshold.
Do not introduce per-review pass/fail votes, a hard-coded reviewer count, a 2-of-3 majority rule, a second outgoing-review quota, or a new critical-failure moderation state. The existing pair count, averaging, completion, and appeal mechanics remain authoritative; this issue only adds the final threshold-backed status.
Student, score, and reporting views
For a pass-fail CourseTask:
- student task/progress and ordinary score views display Not submitted, Passed, or Failed;
- the internal percentage is not presented as a numeric course score;
- authorized administration, moderation, and appeal views can inspect raw points, percentage, and frozen threshold;
- task details do not show a fake coefficient;
- numeric total-score and crosscheck-score recalculation exclude the task;
- exports preserve textual status and quantitative audit evidence in separate fields;
- APIs expose result type so clients do not infer it from nullable values.
A failed result is still a completed assessment attempt and remains visible. It is not the same as no submission or a pending verification. Detailed task/test feedback may expose criterion evidence according to the relevant feedback policy without turning the internal percentage into course points.
History and migration
- Add a typed result-history representation that can store either a numeric course score or pass-fail evidence/status with author, comment, and timestamp.
- For threshold-backed history, retain raw points when supplied, normalized percentage, frozen threshold, derived status, and any authorized override.
- For decision-backed history, retain the assessor, explicit decision, mapped status, comment/reason where supplied, and timestamp.
- Migrate existing CourseTasks to resultType = score.
- Existing TaskResults remain numeric and retain their histories.
- Existing APIs and UI continue to behave as before for score tasks.
- Backfill/migration must not change total scores or ranks.
Acceptance criteria
- A course administrator can create a CourseTask with result type Score or Pass/fail.
- Existing tasks default to Score and behave unchanged.
- A pass-fail task supports Threshold and Assessor decision evaluation modes.
- A threshold-backed task requires a valid threshold from 0 to 100; a decision-backed task rejects thresholds and quantitative assessment input.
- Score weight is required only for Score tasks.
- Pass-fail submissions accept validated raw points or a normalized percentage.
- The server snapshots the threshold and derives Passed/Failed consistently.
- A threshold-backed TaskResult retains quantitative evidence and status while its numeric course score remains null.
- A decision-backed TaskResult retains the authorized assessor and decision while score, percentage, and threshold remain null.
- Technical Screening can remove numeric scoring and use the Mentor's existing decision as Passed/Failed/No result without changing candidate distribution, selection, transfer, waitlist, or deferred-decision behavior.
- Absence of a result is distinct from Failed.
- Backend validation rejects payloads that do not match the CourseTask result type.
- Manual single/bulk entry, CSV, and autotest integration support both result types.
- Student and ordinary score views render Passed/Failed rather than internal percentages or numeric sentinels as course scores.
- Authorized audit/moderation views can inspect raw points, percentage, threshold, history, and override reason.
- A pass/fail crosscheck preserves the configured pair count and current
pair count - 1aggregation, then derives one final status from the aggregate percentage and frozen threshold. - Pass-fail tasks and their internal review percentages are excluded from numeric total and crosscheck score recalculation.
- Existing numeric CourseTasks retain identical totals and ranks after migration.
- Tests cover threshold boundaries (including equality), normalization, invalid maxima/ranges, decision authorization and mapping, Technical Screening without a score, persistence, history, administration UI, submissions, verification integration, totals, display, and exports.
Scope boundary
This issue establishes threshold-backed and authorized-decision-backed pass/fail semantics for CourseTask and TaskResult. Crosscheck keeps its existing configured pair count, pair count - 1 aggregation, and appeal workflow; the aggregate percentage is simply evaluated through the same threshold-backed result model. Project-based interview eligibility and course-specific certificate rules consume the resulting Passed/Failed state separately.
Source: rolling-scopes/rsschool-app