Integrity & Invariants
This chapter documents the key invariants and integrity constraints that ensure system correctness throughout the semester lifecycle.
When feasible, enforce constraints at the database level. For complex business rules, use application-level validations and background reconciliation jobs.
1. Registration & Allocation
Database Constraints
# One confirmed submission per user per campaign
add_index :registration_submissions,
[:registration_campaign_id, :user_id],
unique: true,
where: "status = 'confirmed'",
name: "idx_unique_confirmed_submission"
# Unique preference ranks per user per campaign
add_index :registration_submissions,
[:user_id, :registration_campaign_id, :preference_rank],
unique: true,
where: "preference_rank IS NOT NULL",
name: "idx_unique_preference_rank"
Application-Level Invariants
| Invariant | Enforcement |
|---|---|
Registration::UserRegistration.status ∈ {pending, confirmed, rejected} | Enum validation |
| At most one confirmed submission per (user, campaign) | Unique index |
| Preference-based campaigns: each pending submission has unique rank | Unique index + validation |
| Capacity never exceeded at allocation | Allocation algorithm respects registerable.capacity |
| Campaign finalized exactly once | finalize! idempotent with status check |
A campaign holding an Exam item holds exactly one item | Validation on Registration::Item |
A campaign holding an Exam item is first_come_first_served | Validations on Registration::Campaign and Registration::Item |
Only Tutorial displaces a sibling assignment (self.exclusive_assignment?) | Concern default false; pinned by registerable_spec |
assigned_count matches confirmed submissions | Background reconciliation job |
| Assigned users = confirmed UserRegistrations (registration data) | Count from Registration::UserRegistration.where(status: :confirmed) |
| Allocated users = materialized roster (domain data) | Count from rosterable.allocated_user_ids |
| After finalization: assigned users = allocated users | Materialization ensures consistency |
2. Rosters & Materialization
Core Invariants
| Invariant | Details |
|---|---|
| Initial roster snapshot | materialize_allocation! sets roster to match confirmed submissions |
| Historical integrity | Post-allocation roster changes don't mutate Registration::UserRegistration records |
| Atomic operations | Roster::MaintenanceService uses transactions |
| Capacity enforcement | Enforced unless explicit override by staff |
| Audit trail | All roster changes logged with actor, reason, timestamp |
Reconciliation
Background job periodically checks:
- Roster user count vs. capacity limit
- Orphan roster entries (user deleted but still in roster)
3. Assessments & Grading
Grading Lifecycle Strictness & Clean Slate Policy
To prevent ambiguous states and stale feedback during grading, the following business rules are enforced:
1. No Early Grading:
Grading values cannot be entered or modified as long as the assessable remains active (e.g., an Assignment within its deadline or grace period). Standard models use the polymorphic Assessment::Assessable#grading_open? interface to define when the assessment is locked for submissions but open for grading. Modifying any grading attribute (or updating the status away from :pending) explicitly raises an :early_grading_not_allowed error.
2. Clean Slate on Submission Update:
When a student uploads a new submission file (either originally or when a teacher extends a deadline, making an expired assignment active again), SubmissionsController#sync_assessment_participations triggers a "clean slate" reset. This forcibly reverts their participation to :pending, clears total points, explicit numeric/text grades, grading metadata (graded_at, grader_id), and entirely deletes all internal TaskPoint entries. This explicitly guarantees a tutor's previous evaluations aren't accidentally carried over to the overriding new manuscript.
Database Constraints
# One participation per (assessment, user)
add_index :assessment_participations,
[:assessment_id, :user_id],
unique: true,
name: "idx_unique_participation"
# One task point per (participation, task)
add_index :assessment_task_points,
[:participation_id, :task_id],
unique: true,
name: "idx_unique_task_point"
# At most one active grade scheme per assessment; inactive ones accumulate
add_index :assessment_grade_schemes,
:assessment_id,
unique: true,
where: "active = true",
name: "idx_assessment_grade_schemes_one_active"
# Foreign key integrity
add_foreign_key :assessment_tasks, :assessments
add_foreign_key :assessment_task_points, :assessment_tasks, column: :task_id
add_foreign_key :assessment_task_points, :assessment_participations, column: :participation_id
Application-Level Invariants
| Invariant | Enforcement |
|---|---|
Participation.total_points = sum(task_points.points) | Automatic recomputation on TaskPoint save |
TaskPoint.points ≤ Task.max_points | Validation on save |
Task records exist only if Assessment has tasks | Validation |
Results visible only when Assessment.results_published? returns true | Controller authorization |
Participation.submitted_at persists across status changes | Never overwritten after initial set |
An exempt participation carries no grade | AbsenceHandling#mark_exempt clears grade_numeric, grader, graded_at; the applier never targets exempt rows |
An absent participation is a failed attempt (5.0) | Written by GradeSchemeApplier#apply!; status stays absent |
For MC exam-specific constraints, see the Multiple Choice Exams chapter.
4. Student Performance & Certification
Database Constraints
# One performance record per (lecture, user)
add_index :student_performance_records,
[:lecture_id, :user_id],
unique: true,
name: "idx_unique_performance_record"
# One certification per (lecture, user)
add_index :student_performance_certifications,
[:lecture_id, :user_id],
unique: true,
name: "idx_unique_certification"
# Foreign key integrity
add_foreign_key :student_performance_certifications,
:student_performance_records,
column: :record_id,
optional: true
add_foreign_key :student_performance_certifications,
:student_performance_rules,
column: :rule_id,
optional: true
Application-Level Invariants
| Invariant | Enforcement |
|---|---|
| One Record per (lecture, user) | Unique index |
| One Certification per (lecture, user) | Unique index |
| Records store only factual data (points, achievements) | No eligibility interpretation in Record model |
| Certifications store teacher decisions (passed/failed/pending) | Status enum validation |
Certification.status ∈ {passed, failed, pending} | Enum validation |
| Campaigns cannot open with pending certifications | Pre-flight validation in Campaign model |
| Campaigns cannot finalize with stale certifications | Pre-finalization validation |
Manual certification requires note field | Validation when certified_by present |
| Record recomputation preserves existing Certifications | Certification stability—only flagged for review, not auto-updated |
Certification certified_at timestamp immutable | Set once, never changed (new certification created for updates) |
Certification Lifecycle Invariants
| Phase | Invariant | Details |
|---|---|---|
| Before Registration | All students have Certifications | Pre-flight check blocks campaign opening |
| During Registration | No pending certifications exist | All must be passed or failed |
| Runtime Policy Check | Policy looks up Certification.status | No runtime recomputation |
| Grade Change | Record recomputed, Certification flagged stale | Teacher must review before next campaign |
| Rule Change | All Certifications flagged for review | Teacher sees diff and must re-certify |
5. Grade Schemes
Invariants
| Invariant | Details |
|---|---|
| At most one active scheme per assessment | Assessment belongs_to :grade_scheme |
Identical version_hash = no-op | Applier checks hash before reapplication |
| Manual overrides preserved | Overridden participations skipped during reapplication |
| Bands cover full range | Validation ensures 0.0 to 1.0 coverage |
6. Allocation Algorithm
Preference-Based (Flow Network)
| Invariant | Details |
|---|---|
| Each user assigned to ≤ 1 item | Flow solver ensures exclusivity |
| Total assigned to item ≤ capacity | Capacity constraint in network |
| Unassigned users get dummy edge | If allow_unassigned = true |
| No partial writes on failure | Transaction rollback on solver error |
First-Come-First-Served
| Invariant | Details |
|---|---|
| Submissions processed in timestamp order | Ordered query by created_at |
| Capacity checked atomically | Database-level row locking |
| Concurrent submissions handled safely | Pessimistic locking or retry logic |
7. Policy Engine
Invariants
| Invariant | Details |
|---|---|
Policies evaluated in ascending position order | Stable sort ensures deterministic evaluation |
| First failure short-circuits | Remaining policies not evaluated |
| No side effects on policy failure | Read-only policy checks |
| Policy trace retained per request | For debugging and audit purposes |
8. Data Consistency Reconciliation
Recommended Background Jobs
The jobs listed here are implemented alongside the features they support
(e.g., RecountAssignedJob in Step 5, ParticipationTotalsJob in
Step 8). An admin integrity dashboard for monitoring these jobs is a
future extension.
Performance record recomputation does not require a background job:
after_commit callbacks on grading models (Assessment::Participation,
Assessment::TaskPoint, Achievement, Assessment::Assessment,
Assessment::Task, LectureMembership) call ComputationService
synchronously.
Stale certifications need no job either. Certification.stale derives
staleness by comparing certified_at against the record and the rule, so
there is nothing to flag and nothing to keep current. Step 10's
certification overview evaluates the scope on each request and separates
stale-by-rule from stale-by-data.
| Job | Purpose | Frequency |
|---|---|---|
RecountAssignedJob | Recompute assigned_count from confirmed submissions | Hourly |
ParticipationTotalsJob | Verify total_points matches sum of task points | Daily |
OrphanTaskPointsJob | Detect task points with missing participation/task | Weekly |
RosterIntegrityJob | Check roster user counts vs. capacities | Daily |
9. Idempotency Patterns
| Operation | Idempotency Strategy |
|---|---|
Campaign.finalize! | Check status != :finalized before proceeding |
materialize_allocation! | Replace entire roster (not additive) |
Assessment::GradeSchemeApplier.apply! | Compare version_hash; skip if unchanged |
StudentPerformance::ComputationService.compute! | Upsert pattern preserves overrides |
Roster::MaintenanceService operations | Each operation atomic with validation |
10. Security & Authorization
| Resource | Permission | Enforcement |
|---|---|---|
| Campaigns | Create/modify | Staff only |
| Policies | Create/modify | Staff only |
| Submissions | Create | User for self, open campaign |
| Rosters | Modify | Staff only via MaintenanceService |
| Grades | Enter/modify | Staff/tutors only |
| Eligibility overrides | Set | Staff only with audit trail |
11. Monitoring & Alerts
Key Metrics
| Metric | Threshold | Action | Explanation |
|---|---|---|---|
| Orphan submissions | = 0 | Alert immediately | Submissions without a valid registration_item_id indicate broken foreign keys or data corruption |
| Allocation failures (last 24h) | > 0 | Alert staff | Failed registration assignments need manual review; may indicate capacity or constraint issues |
| Count drift per item | > 5 | Trigger recount job | Difference between assigned_count cache and actual roster count suggests cache staleness |
| Pending certifications during active campaigns | > 0 | Alert staff | Campaigns should not have pending certifications; blocks campaign operations |
| Stale certifications | > 10% of total | Alert staff | High staleness rate suggests Records are being recomputed but Certifications not reviewed |
| Performance record age during grading period | > 48h | Trigger recomputation | Stale Records mean certifications are based on outdated data |
The "count drift" metric compares the cached assigned_count field on registration items against the actual number of confirmed roster entries. A drift > 5 suggests the cache is out of sync with reality, which can happen after manual roster modifications or failed callbacks. The recount job refreshes these cached values.
Points exceeding task maximum are intentionally permitted to support extra credit scenarios and bonus points. This is not considered an error condition.
12. Audit Checklist
Use this checklist for manual verification:
- Random sample: confirmed submission IDs match roster user IDs (for recently finalized campaigns)
-
Random sample:
total_pointsmatchessum(task_points.points)for assessments -
All certifications with manual overrides have non-null
notefield - No pending certifications exist during active registration campaigns
- All lecture students have Certifications before exam campaign opens
-
Registration policy
positionvalues are continuous (no gaps) per campaign - Roster changes have audit trail entries
- No orphan task points (all reference valid participation + task)
- Assigned users (registration data) match allocated users (roster data) after finalization
- Certifications are not auto-updated when Records change (stability check)