Background
Tournament currently stores two structural concepts as opaque JSON blobs:
-
Tournament.blocks — a list of {number, label, date, start, end} dicts representing tournament-wide time blocks. No UI has ever been built against this — the field has sat unused (blocks: [] hardcoded on every create) since it was added.
-
Tournament.volunteer_schema — a dict containing positions (role definitions with attached permissions) and custom_fields (ad-hoc extra volunteer data fields). TournamentMembership.positions mirrors this as a plain JSON array of role key strings assigned to that member (both field names still say "position" in the current codebase — this issue proposes renaming the concept to "role").
This was reasonable as a fast first pass, but it's become a real limitation:
-
Not queryable. "Who currently holds role X in this tournament" requires loading every membership row and scanning its JSON array in application code — there's no way to ask the database directly.
-
No relational integrity. Nothing stops a membership from referencing a role key that was renamed or deleted out from under it; nothing enforces that a role key is unique within a tournament beyond hand-written validation.
-
No rank/hierarchy concept. Every role is flat — there's no way to say "this role can manage staff, but not staff at a higher level than itself," which will be needed before staff-management permissions can be safely delegated below the TD.
-
No real ownership concept. "Who created/ultimately controls this tournament" isn't modeled — today it's implicitly whoever happens to hold the most-permissive role, which isn't transferable or protected.
-
Blocks conflate two different concerns. Time-block scheduling was originally modeled at the tournament level, but the actual need is time attached to events (an event runs 8am–3pm, not "the tournament has 8 generic blocks"). No UI was ever built on the old model precisely because this mismatch made it awkward to work with.
-
No tournament visibility/lifecycle model exists at all. There's currently no way to mark a tournament as publicly discoverable, admin-verified, or open for registration — every tournament today is equally "real" with no distinction between a TD's draft and an official, recruiting event.
-
Deadlines can't be fixed columns. TDs will need to define arbitrary custom deadlines/reminders (not just a fixed "interest due / registration due / confirmation due" set) — a fixed-column approach won't scale to that.
-
No audit trail. Once staff can assign/remove roles and manage other staff, there will be no record of who changed what.
Motivation
This is foundational cleanup that several planned features will depend on directly:
- A staff-management flow will need a real permission (
manage_staff), a real place to assign it, and a rank system so staff can't use it to outrank or remove people above them — not another key jammed into the existing JSON blob.
- A public tournament directory ("Explore" page) will need an
is_public/is_verified distinction so unofficial or unreviewed tournaments don't show up as legitimate.
- Private tournaments will need an invite-link mechanism, which will need its own table.
- Tournaments will need a protected, transferable ownership concept independent of the role system, so control of a tournament isn't just "whoever has the most-permissive role right now."
- TDs will need to define their own deadlines/reminders rather than being limited to a fixed set of date fields.
- The upcoming Forms system will need
TournamentMembership to have relationally-sound role/permission data and a real status field to build on, not JSON it has to defensively re-parse.
Rebuilding this now, before more features get stacked on top of the JSON blob approach, will be cheaper than migrating later once staff management, forms, and a public directory all depend on the old shape.
Proposed Changes
To be removed
| Field |
Reason |
Tournament.blocks |
Unused by any UI; time will be moved to individual events in a separate, not-yet-designed epic |
Tournament.volunteer_schema |
Will be replaced by relational tables below |
Tournament.volunteer_schema.custom_fields |
No direct replacement in this phase — will be redesigned later as part of a separate forms system |
TournamentMembership.positions (JSON array) |
Will be replaced by a MembershipRole junction table |
To be added — relational roles, with rank
| Table |
Purpose |
TournamentRole |
One row per role a tournament defines (key, label, permissions[], rank). Will replace volunteer_schema["positions"]. |
MembershipRole |
Junction — which roles a given membership holds. Will replace TournamentMembership.positions. |
permissions will stay a JSON array within TournamentRole rather than its own junction table — permissions are a small, fixed, code-defined set (ALL_PERMISSIONS in permissions.py); adding one requires a deploy, not a runtime action, so there's no relational benefit to normalizing them further, and doing so would add a join to the permission-check hot path for no query gain.
rank will be new: lower number = higher authority, ties will be allowed and expected (e.g. the four coordinator roles sharing a rank). It will bound what a MANAGE_STAFF holder can do — see below.
Default role template (to be seeded on tournament creation): Tournament Director (rank 1); Volunteer/Test/Materials/Logistics Coordinator (rank 2, tied — collectively "tournament staff" alongside TD); Test Writer, Lead Event Supervisor, Volunteer (rank 3, tied, no permissions attached). TDs will be able to create custom roles at any rank between 1 and their own.
To be added — tournament ownership
Tournament.owner_id — a direct FK to the creating user, not a role and not ranked (it will sit structurally above rank 1). Exactly one owner per tournament at all times. Transferable only by the current owner, via a dedicated endpoint; the outgoing owner will keep whatever TournamentRole assignments they already held (e.g. if they were also TD, they'll remain TD after losing ownership).
To be added — new permission and rank-gated staff management
MANAGE_STAFF — a new entry in ALL_PERMISSIONS, not implied by any other permission except MANAGE_TOURNAMENT. Will let a TD grant "can assign/remove roles" without granting full tournament control.
New assign/remove-role routes will be gated by both the permission and a rank check: a MANAGE_STAFF holder will only be able to assign or remove roles at or below their own rank, never above. Self-modification (including self-demotion) will be allowed at the API level — this is intended as a frontend UX warning ("this is effectively irreversible without another staff member reassigning you"), not a backend restriction. Owner and MANAGE_TOURNAMENT holders will bypass the rank check entirely.
Staff-invite UI (search-by-name/email, invite emails) is explicitly not part of this phase — only the backend assign/remove-role mechanism is in scope.
To be added — tournament visibility & scheduling-window fields
| Field |
Purpose |
is_public |
TD-controlled. Will be shown on the public tournament directory. |
is_verified |
Admin-controlled only, via a dedicated admin route. Will mark a tournament as officially reviewed. |
registration_opens_at |
Tournament can be public before this date — will let people "watch" before signups open. Kept as a fixed column (unlike the deadlines below) because it's load-bearing logic, not just informational. |
default_day_start, default_day_end |
Sane pre-fill bounds for use elsewhere (availability tooling) — not itself a scheduling feature. |
To be added — TD-defined deadlines
TournamentDeadline — will replace the earlier idea of fixed interest_due_at / registration_due_at / confirmation_due_at columns. TDs need to be able to define arbitrary custom deadlines and reminders, not a fixed set of three, so this will be a table instead: label, due_at, reminder_offsets (days-before, for future notification scheduling), visibility (public vs internal).
To be added — membership status
TournamentMembership.status: "interested" (default on join) or "confirmed" (to be set automatically when the confirmation form is submitted, once the Forms system exists). Status will not gate role or event assignment — tournaments commonly want to plan ahead and assign roles/events to interested-but-not-yet-confirmed members. No declined/removed states: a member who doesn't confirm will be removed from the tournament by deleting their membership row rather than tracking a terminal status.
To be added — private-tournament invite links
TournamentJoinCode — same shape as the existing ChapterJoinCode pattern (8-char code, optional label/expiry, active flag). Redeeming a code will create a bare TournamentMembership with no roles attached and status="interested" — staff will assign roles afterward, same as a Discord invite link grants base membership only, not roles. Whether a code should auto-deactivate at a registration deadline is deferred, not decided in this phase.
To be added — per-tournament audit log
AuditLogEntry — will record role creation/assignment/removal, deadline changes, verification, join-code creation, and ownership transfers, each tied to tournament_id and actor_id (the Owner will be logged as a normal actor, no special case). Scoped per-tournament only — tournaments don't live inside chapters, so there's no natural cross-tournament scope for this log.
What Will Not Change
TournamentEvent and anything event/time related — explicitly out of scope, belongs to a separate future epic.
TournamentMembership.assigned_event_id, .schedule, .role_preference, .event_preference, .availability, .lunch_order, .extra_data — will stay untouched, each has its own later redesign planned (structural availability/lunch tables, Forms system).
- Self-serve "leave tournament" for members — deferred to the later membership/volunteer-management refactor.
- Roster/registration for competing schools, scoring/results, public results pages — out of scope for the project entirely, not just this phase.
Completion Criteria
- Creating a tournament will set
owner_id to the creator and auto-populate TournamentRole rows (with rank) from the default template, or TD-supplied roles.
- Assigning a role to a membership will create a
MembershipRole row; permission checks (get_user_permissions()) will reflect it correctly, and Owner status will grant full permissions independent of role assignments.
- A
MANAGE_STAFF holder will not be able to assign or remove a role ranked above their own; Owner/MANAGE_TOURNAMENT will bypass this restriction.
Tournament.blocks and Tournament.volunteer_schema will no longer exist as columns; interest_due_at/registration_due_at/confirmation_due_at will never exist as fixed columns (superseded by TournamentDeadline).
- A TD will be able to create a
TournamentJoinCode; redeeming it will create a membership with status="interested" and no roles attached.
is_verified will only be changeable via the admin-only route — a TD PATCHing their own tournament will not be able to set it.
- Only the current owner will be able to transfer ownership; the outgoing owner will retain their prior role assignments after transfer.
- Role, deadline, verification, join-code, and ownership-transfer actions will each produce a corresponding
AuditLogEntry row.
- All existing tests will pass against the new shape; new tests will cover role CRUD (including rank bounds), permission resolution, rank-gated staff assignment, ownership transfer, and join-code redemption.
Background
Tournamentcurrently stores two structural concepts as opaque JSON blobs:Tournament.blocks— a list of{number, label, date, start, end}dicts representing tournament-wide time blocks. No UI has ever been built against this — the field has sat unused (blocks: []hardcoded on every create) since it was added.Tournament.volunteer_schema— a dict containingpositions(role definitions with attached permissions) andcustom_fields(ad-hoc extra volunteer data fields).TournamentMembership.positionsmirrors this as a plain JSON array of role key strings assigned to that member (both field names still say "position" in the current codebase — this issue proposes renaming the concept to "role").This was reasonable as a fast first pass, but it's become a real limitation:
Not queryable. "Who currently holds role X in this tournament" requires loading every membership row and scanning its JSON array in application code — there's no way to ask the database directly.
No relational integrity. Nothing stops a membership from referencing a role key that was renamed or deleted out from under it; nothing enforces that a role key is unique within a tournament beyond hand-written validation.
No rank/hierarchy concept. Every role is flat — there's no way to say "this role can manage staff, but not staff at a higher level than itself," which will be needed before staff-management permissions can be safely delegated below the TD.
No real ownership concept. "Who created/ultimately controls this tournament" isn't modeled — today it's implicitly whoever happens to hold the most-permissive role, which isn't transferable or protected.
Blocks conflate two different concerns. Time-block scheduling was originally modeled at the tournament level, but the actual need is time attached to events (an event runs 8am–3pm, not "the tournament has 8 generic blocks"). No UI was ever built on the old model precisely because this mismatch made it awkward to work with.
No tournament visibility/lifecycle model exists at all. There's currently no way to mark a tournament as publicly discoverable, admin-verified, or open for registration — every tournament today is equally "real" with no distinction between a TD's draft and an official, recruiting event.
Deadlines can't be fixed columns. TDs will need to define arbitrary custom deadlines/reminders (not just a fixed "interest due / registration due / confirmation due" set) — a fixed-column approach won't scale to that.
No audit trail. Once staff can assign/remove roles and manage other staff, there will be no record of who changed what.
Motivation
This is foundational cleanup that several planned features will depend on directly:
manage_staff), a real place to assign it, and a rank system so staff can't use it to outrank or remove people above them — not another key jammed into the existing JSON blob.is_public/is_verifieddistinction so unofficial or unreviewed tournaments don't show up as legitimate.TournamentMembershipto have relationally-sound role/permission data and a real status field to build on, not JSON it has to defensively re-parse.Rebuilding this now, before more features get stacked on top of the JSON blob approach, will be cheaper than migrating later once staff management, forms, and a public directory all depend on the old shape.
Proposed Changes
To be removed
Tournament.blocksTournament.volunteer_schemaTournament.volunteer_schema.custom_fieldsTournamentMembership.positions(JSON array)MembershipRolejunction tableTo be added — relational roles, with rank
TournamentRolekey,label,permissions[],rank). Will replacevolunteer_schema["positions"].MembershipRoleTournamentMembership.positions.permissionswill stay a JSON array withinTournamentRolerather than its own junction table — permissions are a small, fixed, code-defined set (ALL_PERMISSIONSinpermissions.py); adding one requires a deploy, not a runtime action, so there's no relational benefit to normalizing them further, and doing so would add a join to the permission-check hot path for no query gain.rankwill be new: lower number = higher authority, ties will be allowed and expected (e.g. the four coordinator roles sharing a rank). It will bound what aMANAGE_STAFFholder can do — see below.Default role template (to be seeded on tournament creation): Tournament Director (rank 1); Volunteer/Test/Materials/Logistics Coordinator (rank 2, tied — collectively "tournament staff" alongside TD); Test Writer, Lead Event Supervisor, Volunteer (rank 3, tied, no permissions attached). TDs will be able to create custom roles at any rank between 1 and their own.
To be added — tournament ownership
Tournament.owner_id— a direct FK to the creating user, not a role and not ranked (it will sit structurally above rank 1). Exactly one owner per tournament at all times. Transferable only by the current owner, via a dedicated endpoint; the outgoing owner will keep whateverTournamentRoleassignments they already held (e.g. if they were also TD, they'll remain TD after losing ownership).To be added — new permission and rank-gated staff management
MANAGE_STAFF— a new entry inALL_PERMISSIONS, not implied by any other permission exceptMANAGE_TOURNAMENT. Will let a TD grant "can assign/remove roles" without granting full tournament control.New assign/remove-role routes will be gated by both the permission and a rank check: a
MANAGE_STAFFholder will only be able to assign or remove roles at or below their own rank, never above. Self-modification (including self-demotion) will be allowed at the API level — this is intended as a frontend UX warning ("this is effectively irreversible without another staff member reassigning you"), not a backend restriction. Owner andMANAGE_TOURNAMENTholders will bypass the rank check entirely.Staff-invite UI (search-by-name/email, invite emails) is explicitly not part of this phase — only the backend assign/remove-role mechanism is in scope.
To be added — tournament visibility & scheduling-window fields
is_publicis_verifiedregistration_opens_atdefault_day_start,default_day_endTo be added — TD-defined deadlines
TournamentDeadline— will replace the earlier idea of fixedinterest_due_at/registration_due_at/confirmation_due_atcolumns. TDs need to be able to define arbitrary custom deadlines and reminders, not a fixed set of three, so this will be a table instead:label,due_at,reminder_offsets(days-before, for future notification scheduling),visibility(publicvsinternal).To be added — membership status
TournamentMembership.status:"interested"(default on join) or"confirmed"(to be set automatically when the confirmation form is submitted, once the Forms system exists). Status will not gate role or event assignment — tournaments commonly want to plan ahead and assign roles/events to interested-but-not-yet-confirmed members. Nodeclined/removedstates: a member who doesn't confirm will be removed from the tournament by deleting their membership row rather than tracking a terminal status.To be added — private-tournament invite links
TournamentJoinCode— same shape as the existingChapterJoinCodepattern (8-char code, optional label/expiry, active flag). Redeeming a code will create a bareTournamentMembershipwith no roles attached andstatus="interested"— staff will assign roles afterward, same as a Discord invite link grants base membership only, not roles. Whether a code should auto-deactivate at a registration deadline is deferred, not decided in this phase.To be added — per-tournament audit log
AuditLogEntry— will record role creation/assignment/removal, deadline changes, verification, join-code creation, and ownership transfers, each tied totournament_idandactor_id(the Owner will be logged as a normal actor, no special case). Scoped per-tournament only — tournaments don't live inside chapters, so there's no natural cross-tournament scope for this log.What Will Not Change
TournamentEventand anything event/time related — explicitly out of scope, belongs to a separate future epic.TournamentMembership.assigned_event_id,.schedule,.role_preference,.event_preference,.availability,.lunch_order,.extra_data— will stay untouched, each has its own later redesign planned (structural availability/lunch tables, Forms system).Completion Criteria
owner_idto the creator and auto-populateTournamentRolerows (with rank) from the default template, or TD-supplied roles.MembershipRolerow; permission checks (get_user_permissions()) will reflect it correctly, and Owner status will grant full permissions independent of role assignments.MANAGE_STAFFholder will not be able to assign or remove a role ranked above their own; Owner/MANAGE_TOURNAMENTwill bypass this restriction.Tournament.blocksandTournament.volunteer_schemawill no longer exist as columns;interest_due_at/registration_due_at/confirmation_due_atwill never exist as fixed columns (superseded byTournamentDeadline).TournamentJoinCode; redeeming it will create a membership withstatus="interested"and no roles attached.is_verifiedwill only be changeable via the admin-only route — a TD PATCHing their own tournament will not be able to set it.AuditLogEntryrow.