Registration System
A registration system manages time-bounded processes where users sign up for course-related activities with constraints and preferences.
- Common Examples: "Tutorial signup for Linear Algebra", "Seminar talk selection", "Exam registration with eligibility checks"
- In this context: A flexible campaign-based system supporting direct assignment, preference-based allocation, and composable eligibility policies with automated domain materialization.
Problem Overview
MaMpf needs a flexible registration system to handle:
- Regular courses: Students register for tutorials within a lecture
- Seminars: Students register for talks within a seminar (special type of lecture)
- Mixed scenarios: Combining lecture enrollment with tutorial/talk assignment via a chained process
Solution Architecture
We use a unified system with:
- Registration Campaigns: Time-bounded processes for registration
- Polymorphic Design: Any model can become registerable or campaignable (host campaigns)
- Two-step Chaining: Optional prerequisite campaigns (e.g., must register for seminar before selecting talks) implemented via a
prerequisite_campaignpolicy - Allocation Persistence: Store the final allocation (confirmed vs rejected) and optional per-item counters
- Strategy Layer: Pluggable solver for preference-based allocation (Min-Cost Flow now; CP-SAT later)
- Domain Materialization (mandatory): After allocation, propagate confirmed assignments back into domain models (e.g., populate talk speakers, tutorial rosters)
- Registration Policies: Composable eligibility rules (student performance, institutional email, prerequisite, etc.)
- Policy Phases: Policies declare a phase:
registration,finalization, orboth. Only policies applicable to the current phase are evaluated/enforced. See Student Performance → Certification (05-student-performance.md) for how finalization uses Certification. - Policy Engine: Phase-aware evaluation of ordered active policies; short-circuits on first failure
- Allocation mode: Enum selecting
first_come_first_servedorpreference_based. - AllocationService: Computes allocations (preference-based) via
allocate!. - AllocationMaterializer: Applies confirmed allocations to domain rosters.
- Campaign methods:
allocate!,finalize!,allocate_and_finalize!. - Policy phases:
registrationgates intake;finalizationgates roster materialization;bothapplies in both places. - Assigned users: Users with
confirmedstatus in the registration system (Registration::UserRegistration.confirmed). This is registration-side data. - Allocated users: Users materialized into the domain roster after finalization (
Tutorial#students,Talk#speakers, etc.). This is domain-side data. After finalization, assigned and allocated should match.
Registration::Campaign (ActiveRecord Model)
The Registration Process Orchestrator
A time-bounded administrative process where users can register for specific items under a chosen mode.
The main fields and methods of Registration::Campaign are:
| Name/Field | Type/Kind | Description |
|---|---|---|
campaignable_type | DB column | Polymorphic type for the campaign host (e.g., Lecture) |
campaignable_id | DB column | Polymorphic ID for the campaign host |
title | DB column | Human-readable campaign title |
allocation_mode | DB column (Enum) | Registration mode: first_come_first_served or preference_based |
status | DB column (Enum) | Campaign state: draft, open, closed, processing, completed |
planning_only | DB column (Bool) | Planning/reporting only; prevents materialization/finalization (default: false) |
registration_deadline | DB column | Deadline for user registrations (registration requests) |
registration_items | Association | Items available for registration within this campaign |
user_registrations | Association | User registrations (registration requests) for this campaign |
registration_policies | Association | Eligibility and other policies attached to this campaign |
evaluate_policies_for(user, phase: :registration) | Method | Returns a structured eligibility result for the given phase (delegates to Policy Engine) |
policies_satisfied?(user, phase: :registration) | Method | Boolean convenience that returns true when all applicable policies pass |
open_for_registrations? | Method | Returns true if campaign is currently accepting registrations |
allocate! | Method | Computes allocation (preference-based) without materialization |
finalize! | Method | Enforces finalization-phase policies, then materializes the latest allocation into domain rosters |
allocate_and_finalize! | Method | Convenience: computes allocation and then finalizes |
Eligibility is not a single field or method, but is determined dynamically by evaluating all active registration_policies for the campaign using the evaluate_policies_for(user, phase:) method, which delegates to the phase-aware policy engine. Use policies_satisfied?(user, phase:) as a boolean convenience.
evaluate_policies_for(user, phase: :registration)→ Result (fields:pass,failed_policy,trace,details)policies_satisfied?(user, phase: :registration)→ Boolean (truewhen all applicable policies pass)open_for_registrations?→ Boolean (campaign currently accepts registrations)
See also: Controller endpoints in Controller Architecture → Registration Controllers.
Behavior Highlights
- Guards registration window (
open?) - Delegates fine-grained eligibility to ordered
RegistrationPoliciesvia Policy Engine - Triggers solver (preference-based) after close (often at/after deadline)
- Finalizes and materializes allocation once only (idempotent)
Assigned vs Unassigned
- Assigned: the student has exactly one
confirmedRegistration::UserRegistrationin the campaign after allocation/close. - Unassigned: the student participated (has registrations) but has zero
confirmedentries. On close/finalization, any remainingpendingentries are normalized torejectedso the state is explicit. - No extra tables are required. Helper methods on
Registration::Campaigncan exposeunassigned_user_ids,unassigned_users, andunassigned_countcomputed fromUserRegistrationrecords.
Statuses are mode-specific:
- First-come-first-served (FCFS): registrations are immediately
confirmedorrejected. - Preference-based: registrations are
pendinguntil allocation, then resolved toconfirmedorrejectedon finalize.
Do not overload pending to represent eligibility uncertainty in FCFS; use policy details (e.g., stability) purely for UI messaging.
Close vs Finalize
- Close registration: stops intake and edits; transitions
open → closed. Used to lock the window early or when the deadline passes automatically. - Run allocation (preference-based only): triggers solver; transitions
closed → processing. FCFS campaigns skip this step (results already determined). - Finalize results: before materialization, evaluates all active policies whose phase is
finalizationorbothfor each confirmed user (via aRegistration::FinalizationGuard). Astudent_performancepolicy in finalization phase requiresCertification=passedfor all confirmed users. If any user fails a finalization-phase policy (or has missing/pending certification) the process aborts and status remainsprocessing(orclosedfor FCFS) for remediation. After passing guards, materializes confirmed results and transitions tocompleted. - Planning-only campaigns: close only; do not call
finalize!. Results remain in reporting tables and are not materialized. Whenplanning_onlyis true,finalize!/allocate_and_finalize!are no-ops. - Lecture performance completeness checks:
- Campaign save: Warns if any students lack certifications (any phase with student_performance policy)
- Campaign open: Hard-fails if any students have missing/pending certifications (registration or both phase)
- Campaign finalize: Hard-fails if any confirmed registrants have missing/pending certifications (finalization or both phase); auto-rejects students with failed certifications
See also: Student Performance → Certification (05-student-performance.md).
Currently, campaigns transition draft → open via manual teacher action. A future enhancement could add automatic opening via registration_start timestamp and background job. See Future Extensions - Scheduled Opening for details.
After completion, the Campaign Show can surface an "Unassigned registrants" table (name, matriculation, top preferences) with actions to place users into groups via Roster Maintenance. In roster screens, add a filter "Candidates from campaign X" that lists these unassigned users for quick moves.
Campaign Lifecycle & Freezing Rules
Campaigns transition through several states to ensure data integrity and fair user experience. Certain attributes freeze at specific lifecycle points to prevent inconsistent or unfair changes.
State Definitions:
- draft: Campaign is being configured, not visible to students
- open: Registration window is active, students can register
- closed: Registration window ended (automatically at deadline or manually)
- processing: Allocation algorithm running (preference-based only)
- completed: Results published, rosters materialized
Freezing Rules
Campaign Attributes
| Attribute | Freeze Point | Modification Rules |
|---|---|---|
allocation_mode | After draft | Cannot change once opened. Students make decisions based on mode (early registration for FCFS vs. preference ranking). |
registration_opens_at | After draft | Cannot change once opened. Opening time is in the past. |
registration_deadline | Never | Can be extended anytime. Shortening is allowed but discouraged (confusing UX). |
planning_only | Never | Can be toggled anytime. Affects internal behavior, not student-facing. |
Policies
| Action | Freeze Point | Modification Rules |
|---|---|---|
| Add/Edit/Remove | After draft | Cannot add, edit, or remove policies once opened. New policies could invalidate existing registrations (especially in FCFS where spots are already confirmed). |
Items
| Action | Freeze Point | Modification Rules |
|---|---|---|
| Add item | Never | Can always add new items. Gives students more options without invalidating existing choices. |
| Remove item | After draft | Cannot remove items with existing registrations. Students may have registered for (FCFS) or ranked (preference) that item. |
Capacity Constraints
| Mode | Freeze Point | Modification Rules |
|---|---|---|
| FCFS | Constrained | Can increase anytime. Can decrease only if new_capacity >= confirmed_count for that item. Cannot revoke confirmed spots. |
| Preference-based | After completed | Can change freely while draft, open, or closed (allocation hasn't run). Freezes once completed (results published). |
Implementation Notes
Validation Example:
validate :allocation_mode_frozen_after_open, on: :update
validate :policies_frozen_after_open, on: :update
validate :capacity_decrease_respects_confirmed, on: :update
def allocation_mode_frozen_after_open
if allocation_mode_changed? && !draft?
errors.add(:allocation_mode, "cannot be changed after campaign opens")
end
end
Item Removal:
- Check
item.user_registrations.exists?before allowing deletion - Alternative: Soft-delete (set
active: false) instead of destroying
UI Feedback:
- Disable/gray out frozen fields in forms
- Show tooltips explaining why changes are blocked
- Display warning before opening campaign: "Settings will be locked after opening"
When reopening a completed campaign (transitioning back to open), all freezing rules still apply. The campaign returns to accepting registrations, but fundamental settings (mode, policies, items) remain locked.
Example Implementation (Phase-aware planned state)
module Registration
class Campaign < ApplicationRecord
belongs_to :campaignable, polymorphic: true
has_many :registration_items,
class_name: "Registration::Item",
dependent: :destroy
has_many :user_registrations,
class_name: "Registration::UserRegistration",
dependent: :destroy
has_many :registration_policies,
class_name: "Registration::Policy",
dependent: :destroy
enum allocation_mode: { first_come_first_served: 0, preference_based: 1 }
enum status: { draft: 0, open: 1, closed: 2, processing: 3, completed: 4 }
validates :title, :registration_deadline, presence: true
def evaluate_policies_for(user, phase: :registration)
if phase == :registration
return Registration::PolicyEngine::Result.new(pass: false, code: :campaign_not_open) unless open?
end
engine = Registration::PolicyEngine.new(self)
engine.eligible?(user, phase: phase)
end
def policies_satisfied?(user, phase: :registration)
evaluate_policies_for(user, phase: phase).pass
end
def open_for_registrations?
open?
end
def finalize!
return false if planning_only?
return false unless closed? || processing?
Registration::FinalizationGuard.new(self).check!
Registration::AllocationMaterializer.new(self).materialize!
update!(status: :completed)
end
def allocate!
return false unless preference_based? && closed?
update!(status: :processing)
Registration::AllocationService.new(self, strategy: :min_cost_flow).allocate!
true
end
def allocate_and_finalize!
return false if planning_only?
return false unless allocate!
finalize!
end
def close!
update!(status: :closed) if status == "open"
end
end
end
The system automatically calls close! when registration_deadline is reached via a scheduled job.
Usage Scenarios
Entry points: Teacher/Editor starts at Campaigns index; Student starts at Student Registration index.
- A "Tutorial Registration" campaign is created for a
Lecture. It'spreference_basedand allows students to rank their preferred tutorial slots. Items point toTutorial. (Admin UI: Tutorial Show (open); Student UI: Show – preference-based, Confirmation) - A "Talk Assignment" campaign is created for a
Lecture(often a seminar). It'spreference_basedorfirst_come_first_servedand assigns talk slots. Items point toTalk. - A "Lecture Registration" campaign is created for a
Lecture(commonly seminars). It's typicallyfirst_come_first_servedand enrolls students directly. The single item points to theLecture. (Student UI: Show – FCFS) - A "Seminar Enrollment" campaign is created for a
Lecture(acting as a seminar). It'sfirst_come_first_servedto quickly fill the limited seminar seats. (Student UI: Show – FCFS) - An "Interest Registration" campaign is created for a
Lecturebefore the term to gauge demand (planning-only). It'sfirst_come_first_servedwith a very high capacity; when it ends, you do not callfinalize!. Results are used for hiring/planning and are not materialized to rosters. (Admin UI: Interest Show (draft)) - An "Exam Registration" campaign is created for an
Exam. It isfirst_come_first_servedand may include astudent_performancepolicy (phase:registrationorboth) for advisory eligibility messaging; finalization enforces Certification=passed only if a finalization-phasestudent_performancepolicy exists. Items point toExam. (Admin UI: Exam Show; Student UI: Show – exam (FCFS); see also action required: institutional email)
Planning-only campaigns (Interest Registration)
Goal: Measure demand before a lecture starts to plan staffing (e.g., hire tutors) without changing any rosters.
- Host:
Lecture(campaignable). - Items: Single item pointing to the
Lecture(registerable). - Mode:
first_come_first_served. - Capacity: Very high (effectively unlimited) to capture demand signal.
- Timing: Open well before the term; close before main registrations.
- Finalization: Do not invoke
finalize!. No domain materialization occurs. - Reporting: Use counts from
Registration::UserRegistration(e.g., confirmed) for planning and exports.
See also the Campaigns index mockups where the planning-only row appears as "Interest Registration" with a note like "Planning only; not materialized".
Registration::Campaignable (Concern)
The Campaign Host
A role for domain models (like Lecture) that allows them to 'host' or own registration campaigns.
The 'container' for a set of related registration campaigns. A lecture 'contains' the campaign for its tutorials.
Responsibilities
- Provides a central point for grouping related campaigns.
- Simplifies finding campaigns related to a specific object (e.g., all registrations for a given lecture).
Example Implementation
# app/models/concerns/registration/campaignable.rb
module Registration
module Campaignable
extend ActiveSupport::Concern
included do
has_many :registration_campaigns,
as: :campaignable,
class_name: "Registration::Campaign",
dependent: :destroy
end
end
end
Implementations Here
Lecture: Hosts campaigns for its tutorials or talks.Exam: Hosts a campaign for exam seat registration.
Registration::Item (ActiveRecord Model)
The Selectable Catalog Entry
A selectable entry in a Registration::Campaign's "catalog". Each entry points to a real-world Registerable object (like a Tutorial or Talk).
-
Restaurant Analogy: An item on a restaurant menu. The
Registerableis the actual dish prepared in the kitchen. TheRegistrationItemis the line on the menu for a specific day (the campaign). You order from the menu, not by pointing at the dish in the kitchen. -
Teaching Analogy: A slot in the registration system. The
Registerableis the actual tutorial group that meets every Monday at 10am. TheRegistrationItemis the entry for that tutorial in this semester's "Linear Algebra" registration (the campaign). Students sign up for the slot in the system, not by walking into the classroom.
The main fields and methods of Registration::Item are:
| Name/Field | Type/Kind | Description |
|---|---|---|
registration_campaign_id | DB column | Foreign key for the parent campaign. |
registerable_type | DB column | Polymorphic type for the registerable object (e.g., Tutorial). |
registerable_id | DB column | Polymorphic ID for the registerable object. |
registration_campaign | Association | The parent Registration::Campaign. |
registerable | Association | The underlying domain object (e.g., a Tutorial instance). |
user_registrations | Association | All user registrations (registration requests) for this item. |
assigned_users | Method | Returns users with confirmed registration (registration system data). |
capacity | Method | The maximum number of users, delegated from the registerable. |
module Registration
class Item < ApplicationRecord
belongs_to :registration_campaign,
class_name: "Registration::Campaign"
belongs_to :registerable, polymorphic: true
has_many :user_registrations,
class_name: "Registration::UserRegistration",
dependent: :destroy
def assigned_users
user_registrations.confirmed.includes(:user).map(&:user)
end
end
end
Usage Scenarios
Each scenario below is the item-side view of the campaign types listed
earlier. The Registration::Item belongs to the associated campaign and
wraps the concrete registerable record that users ultimately get
assigned to.
- For a "Tutorial Registration" campaign: A
RegistrationItemis created for eachTutorial(e.g., "Tutorial A (Mon 10:00)"). Theregisterableassociation points to theTutorialrecord. - For a "Talk Assignment" campaign: A
RegistrationItemis created for eachTalk(e.g., "Talk: Machine Learning Advances"). Theregisterableassociation points to theTalkrecord. - For a "Lecture Registration" campaign: A
RegistrationItemis created for the lecture itself. Theregisterableassociation points to theLecturerecord. This will be useful mostly when the lecture is a seminar.Lecturethen has a dual role: as campaignable and as registerable. - For an "Exam Registration" campaign: A
RegistrationItemis created for the exam itself. Theregisterableassociation points to theExamrecord. The campaign'scampaignableis the parentLecture. Each exam (Hauptklausur, Nachklausur, Wiederholungsklausur) gets its own campaign hosted by the lecture, with that exam as the sole registerable item.
It's crucial to understand the difference between these two concepts:
-
Registration::Registerableis the actual domain object that a user is ultimately assigned to. Think of it as the real-world entity, like aTutorialor aTalk. It's a role provided by a concern. -
Registration::Itemis a proxy or wrapper that makes a registerable object available within a specific campaign. Think of it as a "listing in a catalog." If you have a "Tutorial Registration" campaign, you create oneRegistration::Itemfor eachTutorialthat students can sign up for in that campaign.
Users register for a Registration::Item, not directly for a Registerable. This separation allows the same Tutorial to potentially be part of different campaigns over time without conflict.
Registration::Registerable (Concern)
The Registration Target
A role for domain models (like Tutorial or Talk) that allows them to be the ultimate target of a registration.
The actual group or event a user is enrolled in, such as a specific tutorial group or being assigned as the speaker for a talk.
Responsibilities
- Provide a capacity (fixed column or computed).
- Implement
materialize_allocation!(user_ids:, campaign:)to apply confirmed results idempotently. - Remain agnostic of solver or eligibility logic.
Not Responsibilities
- Eligibility checks (policies handle that).
- Storing pending registrations (that’s
UserRegistration). - Orchestrating allocation (that's the
Registration::Campaign).
Public Interface
| Method | Purpose | Required |
|---|---|---|
capacity | Integer seat count. | Yes |
materialize_allocation!(user_ids:, campaign:) | Persists the authoritative roster for this campaign. | Yes |
allocated_user_ids | Current materialized users from domain roster (delegates to roster system). | Yes |
remaining_capacity, full? | Convenience derived helpers. | Optional |
Example Implementation
# app/models/concerns/registration/registerable.rb
module Registration
module Registerable
extend ActiveSupport::Concern
def capacity
self[:capacity] || raise(NotImplementedError, "#{self.class} must define #capacity")
end
def allocated_user_ids
raise NotImplementedError, "#{self.class} must implement #allocated_user_ids to delegate to roster"
end
def remaining_capacity
[capacity - allocated_user_ids.size, 0].max
end
def full?
remaining_capacity.zero?
end
def materialize_allocation!(user_ids:, campaign:)
raise NotImplementedError, "#{self.class} must implement #materialize_allocation!"
end
end
end
Implementation Details
The Registration::Item model uses belongs_to :registerable, polymorphic: true. Any model that includes the Registration::Registerable concern (e.g., Tutorial, Talk) becomes a valid target for this association.
The materialize_allocation! method is the most critical part of the interface. It is responsible for taking the final list of user_ids from the allocation process and persisting them into the domain model's own roster.
This method must be idempotent, meaning running it multiple times with the same user_ids and campaign produces the same result. A common pattern is to first remove all roster entries associated with the given campaign and then add the new ones, all within a single database transaction. Concrete examples are shown in the Tutorial and Talk sections later in this document.
The allocated_user_ids method must be implemented by each registerable model to delegate to its roster system. This returns the current materialized roster (domain data), as opposed to Registration::Item#assigned_users which returns users with confirmed registrations (registration system data). After finalization, these should match.
Usage Scenarios
- A
TutorialincludesRegisterableto manage its student roster. - A
TalkincludesRegisterableto designate students as its speakers. - A
Lecture(acting as a seminar) includesRegisterableto manage direct enrollment. - A future
Exammodel would includeRegisterableto manage allocation for an exam.
Registration::UserRegistration (ActiveRecord Model)
A User's Application for an Item
A user's 'ballot' or 'application form' for one specific choice. In preference-based mode, it's one ranked choice on their list.
The main fields and methods of Registration::UserRegistration are:
| Name/Field | Type/Kind | Description |
|---|---|---|
user_id | DB column | Foreign key for the user submitting. |
registration_campaign_id | DB column | Foreign key for the parent campaign. |
registration_item_id | DB column | Foreign key for the selected item. |
status | DB column (Enum) | pending, confirmed, rejected. |
preference_rank | DB column | Nullable integer for preference-based mode. |
user | Association | The user who submitted. |
registration_campaign | Association | The parent campaign. |
registration_item | Association | The selected item. |
Behavior Highlights
- The
statustracks the lifecycle:pending(awaiting allocation),confirmed(successful), orrejected(unsuccessful). - The
preference_rankis only used inpreference_basedcampaigns and must be unique per user within a campaign. - In
first_come_first_servedmode, a registration is typically created directly withconfirmedstatus if capacity allows. - Business logic should enforce that a user can only have one
confirmedregistration per campaign.
Example Implementation
module Registration
class UserRegistration < ApplicationRecord
belongs_to :user
belongs_to :registration_campaign,
class_name: "Registration::Campaign"
belongs_to :registration_item,
class_name: "Registration::Item"
enum status: { pending: 0, confirmed: 1, rejected: 2 }
validates :preference_rank,
presence: true,
if: -> { registration_campaign.preference_based? }
validates :preference_rank,
uniqueness: { scope: [:user_id, :registration_campaign_id] },
allow_nil: true
end
end
Usage Scenarios
- Preference-based: Alice submits two
Registration::UserRegistrationrecords for a campaign: one for "Tutorial A" withpreference_rank: 1, and one for "Tutorial B" withpreference_rank: 2. Both havestatus: :pending. - First-Come-First-Served: Bob registers for the "Seminar Algebraic Geometry". A single
Registration::UserRegistrationrecord is created withstatus: :confirmedimmediately, as long as there is capacity.
First-Come-First-Served Workflow
In FCFS mode, registration status is determined immediately upon submission:
Controller Logic (recommended):
# app/controllers/registration/user_registrations_controller.rb
def create
campaign = Registration::Campaign.find(params[:campaign_id])
item = campaign.registration_items.find(params[:item_id])
return unless campaign.policies_satisfied?(current_user, phase: :registration)
status = item.remaining_capacity > 0 ? :confirmed : :rejected
Registration::UserRegistration.create!(
user: current_user,
registration_campaign: campaign,
registration_item: item,
status: status,
preference_rank: nil # Not used in FCFS
)
end
Key Differences from Preference-Based:
| Aspect | FCFS | Preference-Based |
|---|---|---|
| Initial status | :confirmed or :rejected | Always :pending |
| When decided | Immediately on create | After allocation runs |
| Multiple items | User registers for ONE item | User ranks MULTIPLE items |
| Solver needed | No | Yes |
| Finalization | Optional (roster may already be live) | Required |
Capacity Enforcement:
- Check
item.remaining_capacitybefore creating the registration - If capacity exhausted, create with
status: :rejected(no waitlist) - Alternatively, return error and don't create record at all
Registration::Policy (ActiveRecord Model)
A Composable Eligibility Rule
“One rule card” (student performance gate, email domain restriction, prerequisite confirmation).
The main fields and methods of Registration::Policy are:
| Name/Field | Type/Kind | Description |
|---|---|---|
registration_campaign_id | DB column | Foreign key for the parent campaign. |
kind | DB column (Enum) | The type of rule to apply (e.g., student_performance). |
phase | DB column (Enum) | registration, finalization, or both. |
config | DB column (JSONB) | Parameters for the rule (e.g., { "allowed_domains": ["uni-heidelberg.de "] }). |
position | DB column | The evaluation order for policies within a campaign. |
active | DB column | A boolean to enable or disable the policy. |
registration_campaign | Association | The parent Registration::Campaign. |
evaluate(user) | Method | Evaluates the policy for a given user and returns a result hash. |
Behavior Highlights
- Policies are evaluated in ascending
positionorder. - The
PolicyEngineshort-circuits on the first policy that fails. - Returns a structured outcome (
{ pass: true/false, ... }) for clear feedback. - Adding a new rule type involves adding to the
kindenum and implementing its logic inevaluate, with no schema changes required.
The evaluate method of a policy returns a hash. While the top-level structure is consistent (containing a boolean pass key), individual policies can enrich the result with a details hash, providing context-specific information. This is particularly useful for complex rules like student performance eligibility.
For early exam registration messaging, the student_performance policy attaches a concise details hash (points, required_points, stability). Rich progress and "may still become eligible" guidance lives in the Student Performance views, not here.
Example Implementation
module Registration
class Policy < ApplicationRecord
belongs_to :registration_campaign,
class_name: "Registration::Campaign"
acts_as_list scope: :registration_campaign
enum kind: {
student_performance: "student_performance",
institutional_email: "institutional_email",
prerequisite_campaign: "prerequisite_campaign",
custom_script: "custom_script"
}
enum phase: {
registration: "registration",
finalization: "finalization",
both: "both"
}
scope :active, -> { where(active: true) }
scope :for_phase, ->(p) { where(phase: ["both", p.to_s]) }
def evaluate(user)
case kind.to_sym
when :student_performance then eval_student_performance(user)
when :institutional_email then eval_email(user)
when :prerequisite_campaign then eval_prereq(user)
when :custom_script then eval_custom(user)
else fail_result(:unknown_kind, "Unknown policy kind")
end
end
private
def pass_result(code = :ok, details = {})
{ pass: true, code: code, details: details }
end
def fail_result(code, message, details = {})
{ pass: false, code: code, message: message, details: details }
end
def eval_student_performance(user)
lecture = Lecture.find(config["lecture_id"])
cert = StudentPerformance::Certification.find_by(lecture: lecture, user: user)
if cert&.passed?
pass_result(:certification_passed)
else
fail_result(
:certification_not_passed,
"Lecture performance certification required",
certification_status: cert&.status || :missing
)
end
end
def eval_email(user)
allowed = Array(config["allowed_domains"])
return pass_result(:no_constraint) if allowed.empty?
domain = user.email.to_s.split("@").last
if allowed.include?(domain)
pass_result(:domain_ok)
else
fail_result(:domain_blocked, "Email domain not allowed",
domain: domain, allowed: allowed)
end
end
def eval_prereq(user)
prereq_id = config["prerequisite_campaign_id"]
return fail_result(:missing_prerequisite_id, "No prerequisite specified") unless prereq_id
prereq_campaign = Registration::Campaign.find_by(id: prereq_id)
return fail_result(:prerequisite_not_found, "Prerequisite campaign not found") unless prereq_campaign
lecture = prereq_campaign.campaignable
ok = lecture.respond_to?(:roster) && lecture.roster.include?(user)
ok ? pass_result(:prerequisite_ok) : fail_result(:prerequisite_missing, "Not on prerequisite roster")
end
def eval_custom(_user)
pass_result(:custom_not_implemented)
end
end
end
config is stored as JSONB for flexibility, but the UI must present
typed fields per policy kind. Do not expose raw JSON to end users. Normalize
inputs and validate per kind in the model. Controllers whitelist per-kind config keys.
Policy Result Reference
Each policy kind returns a standardized result hash with optional details. The following table documents the expected details keys for each policy kind:
| Policy Kind | Success details Keys | Failure details Keys | Example |
|---|---|---|---|
student_performance | None | certification_status (:missing, :pending, :failed) | { certification_status: :pending } |
institutional_email | None | domain (string), allowed (array of strings) | { domain: "gmail.com", allowed: ["uni.edu"] } |
prerequisite_campaign | None | prerequisite_campaign_id (integer) | { prerequisite_campaign_id: 42 } |
custom_script | Defined by script | Defined by script | N/A (implementation-specific) |
All results include:
pass(boolean): Whether the policy passedcode(symbol): Machine-readable result code (e.g.,:certification_passed,:domain_blocked)message(string, optional): Human-readable message (only on failure)details(hash, optional): Additional context as documented above
Why JSONB for Policy.config?
Policies are composable and heterogeneous. Each kind needs different
parameters (domains list, lecture reference, prerequisite campaign id,
future custom scripts). Using JSONB for config avoids schema churn and
lets us:
- Add new policy kinds without migrations.
- Evolve per-kind parameters independently.
- Keep the public API stable (
kind,config), while the typed UI and per-kind validations enforce structure.
Constraints and guardrails:
- The UI is typed per kind; users never edit raw JSON.
- Models validate allowed keys and shapes per kind.
- Index JSONB keys if needed for queries (e.g.,
config ->> 'lecture_id'). - Only minimal data belongs here. For exam eligibility, thresholds and
criteria live in
StudentPerformance::Rule; the policy stores only{ "lecture_id": <id> }.
See UI: Policies tab in Exam Show.
Usage Scenarios
- Email constraint:
kind: :institutional_email,phase: :registration,config: { "allowed_domains": ["uni.edu"] } - Lecture performance gate (advisory + enforcement):
kind: :student_performance,phase: :both,config: { "lecture_id": 42 } - Prerequisite:
kind: :prerequisite_campaign,phase: :registration,config: { "prerequisite_campaign_id": 55 }
Registration::PolicyEngine (Service Object)
The Eligibility Pipeline
A service that evaluates a user's eligibility by processing all of a campaign's active policies in order.
An 'eligibility checklist' processor that stops at the first failed check and provides a trace.
Public Interface
| Method | Purpose |
|---|---|
initialize(campaign) | Sets up the engine with the campaign whose policies will be used. |
eligible?(user) | Evaluates policies for the user and returns a structured Result. |
Behavior Highlights
- Iterates policies in
positionorder. - Stops at the first failure (fast fail).
- Returns a structured
Resultobject containing the pass/fail status, the policy that failed (if any), and a full trace of all evaluations. - This
Resultobject is used byRegistration::Campaign#evaluate_policies_forto provide clear feedback to the UI.
Unlike other policies, student_performance requires data preparation before the phase starts. Campaign save/open/finalize will validate that all required certifications exist and are non-pending. See Student Performance chapter (05-student-performance.md) for pre-flight validation details.
The student_performance policy checks the Certification table at runtime (no JIT recomputation during registration). Facts (Record) are updated by background jobs or teacher-triggered recomputation. This keeps registration fast and deterministic.
Example Implementation
module Registration
class PolicyEngine
Result = Struct.new(:pass, :failed_policy, :trace, keyword_init: true)
def initialize(campaign)
@campaign = campaign
end
def eligible?(user, phase: :registration)
trace = []
applicable = @campaign.registration_policies.active.for_phase(phase).order(:position)
applicable.each do |policy|
outcome = policy.evaluate(user)
trace << { policy_id: policy.id, kind: policy.kind, phase: policy.phase, outcome: outcome }
return Result.new(pass: false, failed_policy: policy, trace: trace) unless outcome[:pass]
end
Result.new(pass: true, failed_policy: nil, trace: trace)
end
end
end
Usage Scenarios
- A trace showing two passed registration-phase policies and one failed policy produces a clear message to the user.
- A finalization guard iterates confirmed users with
phase: :finalization; any failure aborts materialization.
Registration::AllocationService (Service Object)
The Allocation Solver
A service object that encapsulates the complex logic of assigning users to items based on their preferences and a chosen strategy.
Public Interface
| Method | Purpose |
|---|---|
initialize(campaign, strategy:) | Sets up the service with a campaign and a specific allocation strategy. |
allocate! | Executes the allocation logic based on the chosen strategy. |
Responsibilities
- Takes a
Registration::Campaignas input. - Gathers all
pendingRegistration::UserRegistrationrecords with their preference ranks. - Gathers all
Registration::Itemrecords with their capacities. - Executes a specific allocation strategy (e.g., Min-Cost Flow) to find an optimal assignment.
- Updates the
statusof eachRegistration::UserRegistrationto either:confirmedor:rejectedbased on the solver's output.
Not Responsibilities
- It does not materialize the results into the final domain models (e.g.,
Tutorialrosters). That is handled by theAllocationMaterializercalled withinfinalize!. This keeps the concerns of "solving the assignment" and "persisting the results" separate.
Implementation Details
The service uses a Strategy Pattern to delegate the actual solving to a dedicated class based on the chosen strategy. This allows for different solver implementations (e.g., Min-Cost Flow, CP-SAT) to be used interchangeably.
For a detailed breakdown of the graph modeling and solver implementation, see the Allocation Algorithm Details chapter.
Example Implementation
# This service acts as a dispatcher for different solver strategies.
module Registration
class AllocationService
def initialize(campaign, strategy: :min_cost_flow, **opts)
@campaign = campaign
@strategy = strategy
@opts = opts
end
def allocate!
solver =
case @strategy
when :min_cost_flow then Registration::Solvers::MinCostFlow.new(@campaign, **@opts)
# when :cp_sat then Registration::Solvers::CpSat.new(@campaign, **@opts) # Future
else
raise ArgumentError, "Unknown strategy: #{@strategy}"
end
solver.run
end
end
end
# Example of a concrete solver strategy class.
# See 07-algorithm-details.md for the full implementation.
module Registration
module Solvers
class MinCostFlow
def initialize(campaign, **opts)
@campaign = campaign
# ... gather users, items, preferences ...
end
def run
# 1. Build the graph model for the solver
# 2. Solve the model
# 3. Persist the results back to Registration::UserRegistration statuses
end
end
end
end
Usage Scenarios
- After the deadline for a
preference_basedtutorial registration campaign, a background job callsRegistration::AllocationService.new(campaign).allocate!. The service runs the solver and updates thousands ofRegistration::UserRegistrationrecords to either:confirmedor:rejected. - An administrator manually triggers the assignment for a seminar's talk selection via a button in the UI, which in turn calls this service.
Registration::AllocationMaterializer (Service Object)
The Roster Populator
A service that translates the final allocation results (Registration::UserRegistration statuses) into concrete domain rosters.
The "secretary" that takes the list of confirmed attendees from the registration system and updates the official class lists.
Public Interface
| Method | Purpose |
|---|---|
initialize(campaign) | Sets up the materializer with the campaign to be finalized. |
materialize! | Executes the materialization process. |
Responsibilities
- Gathers all
confirmedRegistration::UserRegistrationrecords for the campaign. - Groups them by their
Registration::Item. - For each
Registration::Item, it callsmaterialize_allocation!on the underlyingregisterableobject, passing the final list of user IDs. - This process is the crucial hand-off from the temporary registration system to the permanent domain models.
Example Implementation
module Registration
class AllocationMaterializer
# Missing top-level docstring, please formulate one yourself 😁
def initialize(campaign)
@campaign = campaign
end
def materialize!
registrations_by_item = @campaign.user_registrations
.confirmed
.includes(:registration_item)
.group_by(&:registration_item)
ActiveRecord::Base.transaction do
registrations_by_item.each do |item, registrations|
user_ids = registrations.map(&:user_id)
item.registerable.materialize_allocation!(user_ids: user_ids, campaign: @campaign)
end
end
end
end
end
Registration::FinalizationGuard (Service Object)
The Finalization Gatekeeper
Ensures every confirmed user passes all finalization-phase policies before roster materialization. For student_performance policies, enforces certification completeness and auto-rejects failed certifications.
Public Interface
| Method | Purpose |
|---|---|
initialize(campaign) | Prepare guard for a campaign. |
check! | Raises on first violation; returns true when all confirmed users pass. Auto-rejects students with failed student performance certifications. |
Example Implementation
module Registration
class FinalizationGuard
def initialize(campaign)
@campaign = campaign
end
def check!
policies = @campaign.registration_policies.active.for_phase(:finalization).order(:position)
return true if policies.empty?
confirmed = @campaign.user_registrations.confirmed.includes(:user)
confirmed.each do |ur|
user = ur.user
policies.each do |policy|
if policy.kind == "student_performance"
lecture = Lecture.find(policy.config["lecture_id"])
cert = StudentPerformance::Certification.find_by(lecture: lecture, user: user)
if cert.nil? || cert.pending?
raise StandardError, "Finalization blocked: certification missing or pending for user #{user.id}"
elsif cert.failed?
ur.update!(status: :rejected)
next
end
else
outcome = policy.evaluate(user)
unless outcome[:pass]
raise StandardError, "Finalization blocked by policy #{policy.id} (#{policy.kind})"
end
end
end
end
true
end
end
end
Behavior Highlights
- Auto-reject failed certifications: Students with
StudentPerformance::Certification.status == :failedare automatically moved torejectedstatus - Hard-fail on missing/pending: If any confirmed student has no certification or
status: :pending, raise error and block finalization - Remediation UI trigger: The error message should trigger UI showing which students need certification resolution
- Other policies: Evaluated normally; any failure blocks finalization
See also: Student Performance → Certification and Pre-flight Validation (05-student-performance.md).
Enhanced Domain Models
The following sections describe how existing MaMpf models will be enhanced to integrate with the registration system.
User (Enhanced)
The Registrant
Example Implementation
class User < ApplicationRecord
has_many :user_registrations,
class_name: "Registration::UserRegistration",
dependent: :destroy
has_many :registration_campaigns,
through: :user_registrations
has_many :registration_items,
through: :user_registrations
end
Lecture (Enhanced)
The Primary Host and Seminar Target
Dual Role
- As
Registration::Campaignable: Can organize tutorial registration or talk selection campaigns. - As
Registration::Registerable: Students can register for the lecture itself (common for seminars).
Example Implementation
class Lecture < ApplicationRecord
include Registration::Campaignable # Can host campaigns for tutorials/talks
include Registration::Registerable # Can be registered for (seminar enrollment)
# ... existing code ...
# Implements the contract from the Registerable concern
def materialize_allocation!(user_ids:, campaign:)
# This method is the hand-off point to the roster management system.
# Its responsibility is to take the final list of user IDs and
# persist them as the official roster for this lecture (seminar),
# sourced from this specific campaign.
#
# The concrete implementation using the Roster::Rosterable concern is detailed
# in the "Allocation & Rosters" chapter.
end
end
Tutorial (Enhanced)
A Common Registration Target
Example Implementation
class Tutorial < ApplicationRecord
include Registration::Registerable
# ... existing code ...
# Implements the contract from the Registerable concern
def materialize_allocation!(user_ids:, campaign:)
# This method is the hand-off point to the roster management system.
# Its responsibility is to take the final list of user IDs and
# persist them as the official roster for this tutorial, sourced
# from this specific campaign.
#
# The concrete implementation using the Roster::Rosterable concern is detailed
# in the "Allocation & Rosters" chapter.
end
end
Talk (Enhanced)
A Target for Speaker Allocation
Example Implementation
class Talk < ApplicationRecord
include Registration::Registerable
# ... existing code ...
# Implements the contract from the Registerable concern
def materialize_allocation!(user_ids:, campaign:)
# Similar to the Tutorial, this method hands off the final list
# of speakers to the roster management system.
#
# The concrete implementation using the Roster::Rosterable concern is detailed
# in the "Allocation & Rosters" chapter.
end
end
Campaign Lifecycle (State Diagram)
stateDiagram-v2
[*] --> draft
draft --> open : open
open --> closed : close (manual or at deadline)
closed --> completed : finalize! (optional)
note right of closed
Regular FCFS campaigns: finalize to materialize rosters.
Planning-only: stay in closed, skip finalize.
end note
ERD
erDiagram
"Registration::Campaign" ||--o{ "Registration::Item" : has
"Registration::Campaign" ||--o{ "Registration::Policy" : has
"Registration::Campaign" ||--o{ "Registration::UserRegistration" : has
"Registration::Item" ||--o{ "Registration::UserRegistration" : has
USER ||--o{ "Registration::UserRegistration" : submits
"Registration::Item" }o--|| REGISTERABLE : polymorphic
"Registration::Campaign" }o--|| CAMPAIGNABLE : polymorphic
Preference-Based State Diagram
stateDiagram-v2
[*] --> draft: Campaign created
draft --> open: Admin opens campaign
open --> closed: Admin closes OR deadline reached
closed --> processing: Allocation runs
processing --> completed: Admin finalizes
note right of draft
Admin configures items,
policies, deadline
end note
note right of open
Users submit preferences;
all status: pending
end note
note right of processing
Registration closed;
run allocation solver
end note
note right of completed
Allocation finalized;
rosters materialized
end note
Sequence Diagram (Preference-Based Flow)
This diagram shows the typical lifecycle for a preference-based campaign.
sequenceDiagram
actor User
participant Controller
participant Campaign as Registration::Campaign
participant UserReg as Registration::UserRegistration
actor Job as Background Job
participant AllocationSvc as Registration::AllocationService
participant Solver as Registration::Solvers::MinCostFlow
participant Materializer as Registration::AllocationMaterializer
participant RegTarget as Registerable (e.g., Tutorial)
rect rgb(235, 245, 255)
note over User,Controller: Registration phase (campaign is open)
User->>Controller: Visit campaign page
Controller->>Campaign: evaluate_policies_for(user, phase: :registration)
alt eligible
Controller-->>User: Show preference ranking form
User->>Controller: Submit preferences
loop for each preference
Controller->>UserReg: create(user_id, item_id, rank)
end
Controller-->>User: Preferences saved
else not eligible
Controller-->>User: Show reason from PolicyEngine
end
end
note over User,Job: Deadline passes
rect rgb(255, 245, 235)
note over Job,RegTarget: Allocation & finalization
Job->>Campaign: allocate_and_finalize!
Campaign->>Campaign: update!(status: :closed)
Campaign->>AllocationSvc: new(campaign).allocate!
AllocationSvc->>Solver: new(campaign).run()
note right of Solver: Build graph, solve, persist statuses
Solver->>UserReg: update_all(status: confirmed/rejected)
Campaign->>Campaign: update!(status: :processing)
Campaign->>Campaign: finalize!
Campaign->>Materializer: new(campaign).materialize!
Materializer->>RegTarget: materialize_allocation!(user_ids, campaign)
note right of RegTarget: Update roster (idempotent)
Campaign->>Campaign: update!(status: :completed)
end
FCFS State Diagram
stateDiagram-v2
[*] --> draft: Campaign created
draft --> open: Admin opens campaign
open --> closed: Admin closes OR deadline reached
closed --> completed: Admin finalizes (optional)
note right of draft
Admin configures items,
policies, deadline
end note
note right of open
Users submit registrations;
immediate confirm/reject
end note
note right of closed
Registration closed;
results visible
end note
note right of completed
For planning-only:
skip finalize!
For materialization:
finalize! applies to rosters
end note
Sequence Diagram (FCFS Flow)
This diagram shows the lifecycle for a first-come-first-served campaign.
sequenceDiagram
actor Student
participant UI as Student UI
participant Controller as UserRegistrationsController
participant Campaign
participant Item
participant UserReg as UserRegistration
participant PolicyEngine
participant Roster as Domain Roster
rect rgb(235, 245, 255)
note over Student,PolicyEngine: Registration phase (campaign is open)
Student->>UI: Visit campaign page
UI->>Controller: GET /campaigns/:id
Controller->>Campaign: find(campaign_id)
Controller->>Campaign: open_for_registrations?
Campaign-->>Controller: true
Controller->>Campaign: evaluate_policies_for(user, phase: :registration)
Campaign->>PolicyEngine: eligible?(user, phase: :registration)
PolicyEngine-->>Campaign: Result(pass: true/false, ...)
Campaign-->>Controller: Result
alt policies fail
Controller-->>UI: Ineligible state
UI-->>Student: Show error: "Not eligible (reason)"
else policies pass
Controller-->>UI: Show register buttons
UI-->>Student: Display available items
Student->>UI: Click "Register for Item X"
UI->>Controller: POST /campaigns/:id/user_registrations
Controller->>Item: find(item_id)
Controller->>Item: remaining_capacity
Item-->>Controller: capacity count
alt capacity available
Controller->>UserReg: create!(status: :confirmed, ...)
UserReg-->>Controller: registration record
Controller-->>UI: Success
UI-->>Student: "Registered successfully"
else capacity exhausted
Controller->>UserReg: create!(status: :rejected, ...)
UserReg-->>Controller: registration record
Controller-->>UI: Info: "No capacity"
UI-->>Student: "Item full, registration rejected"
end
end
end
note over Student,Roster: Later: Admin closes campaign
rect rgb(255, 245, 235)
note over Student,Roster: View results (processing state)
Student->>UI: View results
UI->>Controller: GET /campaigns/:id
Controller->>Campaign: status
Campaign-->>Controller: :closed, :processing, or :completed
Controller-->>UI: Show campaign with status
UI-->>Student: Display confirmed/rejected
end
rect rgb(245, 255, 235)
note over Controller,Roster: Optional: Admin finalizes (materialization)
Controller->>Campaign: finalize!
Campaign->>Campaign: evaluate_policies_for(confirmed_users, phase: :finalization)
alt finalization policies fail
Campaign-->>Controller: Error (stays in :processing)
else finalization policies pass
Campaign->>Item: materialize_allocation!(confirmed_user_ids)
Item->>Roster: Update domain roster
Roster-->>Item: Done
Item-->>Campaign: Done
Campaign->>Campaign: update!(status: :completed)
Campaign-->>Controller: Success
end
end
Proposed Folder Structure
To keep the new components organized according to Rails conventions, the new files would be placed as follows:
app/
├── models/
│ ├── concerns/
│ │ └── registration/
│ │ ├── campaignable.rb
│ │ └── registerable.rb
│ └── registration/
│ ├── campaign.rb
│ ├── item.rb
│ ├── policy.rb
│ └── user_registration.rb
│
└── services/
└── registration/
├── solvers/
│ ├── min_cost_flow.rb
│ └── cp_sat.rb (future)
├── allocation_service.rb
├── allocation_materializer.rb
└── policy_engine.rb
This structure separates the ActiveRecord models, shared concerns, and business logic (service objects and solvers) into their conventional directories.
Key Files
app/models/registration/campaign.rb- Orchestrates the registration processapp/models/registration/user_registration.rb- Records user registrations (registration requests)app/models/registration/policy.rb- Defines eligibility rulesapp/services/registration/allocation_service.rb- Runs allocation solverapp/services/registration/allocation_materializer.rb- Persists results to domain models
Database Tables
registration_campaigns- Campaign orchestration recordsregistration_items- Catalog entries linking campaigns to registerablesregistration_user_registrations- User registration request records with status and preference rankregistration_policies- Eligibility rules with kind, phase, config, and position