55 KiB
02. Data Model
Overview
This document defines the complete Prisma schema and Zod configuration contracts for the MOPC platform redesign. The data model uses Competition and Round terminology (replacing Pipeline/Stage/Track), implements a 5-layer policy precedence system, and introduces new models for deliberation, mentoring workspace, result locking, and assignment governance.
Key Design Principles:
- Competition-centric flow (no intermediate Track layer for main competition)
- Round-type-specific typed configurations (Zod-validated)
- First-class Jury Group entities with per-member policy overrides
- Multi-round submission windows with explicit file requirements
- Deliberation system for final winner confirmation (replaces WinnerProposal)
- Result locking with audit trail
- Mentoring workspace with file promotion to official submissions
Entity Relationship Summary
Program (1) ──── (N) Competition
Competition (1) ──── (N) Round
Competition (1) ──── (N) JuryGroup
Competition (1) ──── (N) SubmissionWindow
Competition (1) ──── (N) SpecialAward
Competition (1) ──── (N) DeliberationSession
Round (N) ──── (1) JuryGroup (optional — for EVALUATION, LIVE_FINAL, DELIBERATION)
Round (N) ──── (1) SubmissionWindow (optional — for INTAKE, SUBMISSION)
Round (1) ──── (N) RoundSubmissionVisibility ──── (N) SubmissionWindow
JuryGroup (1) ──── (N) JuryGroupMember ──── (1) User
JuryGroup (1) ──── (N) Assignment
SubmissionWindow (1) ──── (N) SubmissionFileRequirement
SubmissionWindow (1) ──── (N) ProjectFile
Project (1) ──── (N) ProjectRoundState ──── (1) Round
Project (1) ──── (N) Assignment ──── (1) Evaluation
Project (1) ──── (N) ProjectFile
Project (1) ──── (0..1) MentorAssignment ──── (N) MentorFile ──── (N) MentorFileComment
──── (N) MentorMessage
SpecialAward (N) ──── (1) JuryGroup (optional)
SpecialAward (N) ──── (1) Round (evaluationRound — runs alongside)
DeliberationSession (1) ──── (N) DeliberationVote
DeliberationSession (1) ──── (N) DeliberationResult
DeliberationSession (1) ──── (N) DeliberationParticipant
ResultLock (1) ──── (N) ResultUnlockEvent
Core Models
Competition (replaces Pipeline)
model Competition {
id String @id @default(cuid())
programId String
name String // "MOPC 2026 Competition"
slug String @unique // "mopc-2026"
status CompetitionStatus @default(DRAFT)
// Competition-wide settings (typed, not generic JSON)
categoryMode String @default("SHARED") // "SHARED" (both categories same flow) | "SPLIT" (separate finalist counts)
startupFinalistCount Int @default(3)
conceptFinalistCount Int @default(3)
// Notification preferences
notifyOnRoundAdvance Boolean @default(true)
notifyOnDeadlineApproach Boolean @default(true)
deadlineReminderDays Int[] @default([7, 3, 1]) // Days before deadline to send reminders
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
rounds Round[]
juryGroups JuryGroup[]
submissionWindows SubmissionWindow[]
specialAwards SpecialAward[]
deliberationSessions DeliberationSession[]
resultLocks ResultLock[]
@@index([programId])
@@index([status])
}
enum CompetitionStatus {
DRAFT
ACTIVE
CLOSED
ARCHIVED
}
Round (replaces Stage)
model Round {
id String @id @default(cuid())
competitionId String
name String // "Jury 1 - Semi-finalist Selection"
slug String // "jury-1-semifinalist"
roundType RoundType
status RoundStatus @default(ROUND_DRAFT)
sortOrder Int @default(0)
// Time windows
windowOpenAt DateTime?
windowCloseAt DateTime?
// Round-type-specific configuration (validated by Zod per RoundType)
configJson Json? @db.JsonB
// Optional analytics tag — semantic label for grouping/reporting (e.g., "jury1_selection", "semifinal_docs")
purposeKey String? // Free-form, not an enum — allows flexible analytics without schema changes
// Links to other entities
juryGroupId String? // Which jury evaluates this round (EVALUATION, LIVE_FINAL, DELIBERATION)
submissionWindowId String? // Which submission window this round collects docs for (INTAKE, SUBMISSION)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
competition Competition @relation(fields: [competitionId], references: [id], onDelete: Cascade)
juryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull)
submissionWindow SubmissionWindow? @relation(fields: [submissionWindowId], references: [id], onDelete: SetNull)
projectRoundStates ProjectRoundState[]
assignments Assignment[]
evaluationForms EvaluationForm[]
filteringRules FilteringRule[]
filteringResults FilteringResult[]
filteringJobs FilteringJob[]
evaluationSummaries EvaluationSummary[]
evaluationDiscussions EvaluationDiscussion[]
gracePeriods GracePeriod[]
liveCursor LiveProgressCursor?
liveVotingSession LiveVotingSession?
advancementRules AdvancementRule[]
resultLocks ResultLock[]
// Visible submission windows (which doc rounds jury can see)
visibleSubmissionWindows RoundSubmissionVisibility[]
@@unique([competitionId, slug])
@@unique([competitionId, sortOrder])
@@index([competitionId])
@@index([roundType])
@@index([status])
}
enum RoundType {
INTAKE // Application window — collect initial submissions
FILTERING // AI screening — automated eligibility check
EVALUATION // Jury evaluation — scoring, feedback, advancement decision
SUBMISSION // New submission window — additional docs from advancing teams
MENTORING // Mentor-team collaboration period
LIVE_FINAL // Live ceremony — real-time voting, audience participation
DELIBERATION // Winner confirmation — jury consensus + admin approval
}
enum RoundStatus {
ROUND_DRAFT // Being configured, not visible to participants
ROUND_ACTIVE // Open/in progress
ROUND_CLOSED // Window closed, results pending or finalized
ROUND_ARCHIVED // Historical, read-only
}
ProjectRoundState (replaces ProjectStageState)
model ProjectRoundState {
id String @id @default(cuid())
projectId String
roundId String
state ProjectRoundStateValue @default(PENDING)
enteredAt DateTime @default(now())
exitedAt DateTime?
metadataJson Json? @db.JsonB // Round-type-specific state data
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
@@unique([projectId, roundId])
@@index([projectId])
@@index([roundId])
@@index([state])
}
enum ProjectRoundStateValue {
PENDING // Entered round, awaiting action
IN_PROGRESS // Active (submission in progress, evaluation ongoing)
PASSED // Cleared this round, eligible to advance
REJECTED // Did not pass this round
COMPLETED // Round fully complete for this project
WITHDRAWN // Project withdrew
}
AdvancementRule (replaces StageTransition)
model AdvancementRule {
id String @id @default(cuid())
roundId String // The round this rule applies to (source round)
targetRoundId String? // Where projects advance to (null = next round by sortOrder)
ruleType AdvancementRuleType
configJson Json @db.JsonB // Rule-type-specific config
isDefault Boolean @default(true) // Default advancement path
sortOrder Int @default(0) // Priority when multiple rules exist
createdAt DateTime @default(now())
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
@@index([roundId])
}
enum AdvancementRuleType {
AUTO_ADVANCE // All PASSED projects advance to next round automatically
SCORE_THRESHOLD // Projects above score threshold advance
TOP_N // Top N projects per category advance
ADMIN_SELECTION // Admin manually selects who advances
AI_RECOMMENDED // AI suggests advancement, admin confirms
}
AdvancementRule configJson shapes:
// AUTO_ADVANCE
{ trigger: "on_round_close" | "immediate" }
// SCORE_THRESHOLD
{ minScore: 7.0, metric: "average_global" | "weighted_criteria" }
// TOP_N
{
perCategory: true,
counts: { STARTUP: 10, BUSINESS_CONCEPT: 10 },
tieBreaker: "admin_decides" | "highest_individual" | "revote"
}
// ADMIN_SELECTION
{ requireAIRecommendation: true, showRankings: true }
// AI_RECOMMENDED
{ topN: 10, confidenceThreshold: 0.7, requireAdminApproval: true }
Program
model Program {
id String @id @default(cuid())
name String // e.g., "Monaco Ocean Protection Challenge"
slug String? @unique // URL-friendly identifier
year Int // e.g., 2026
status ProgramStatus @default(DRAFT)
description String?
settingsJson Json? @db.JsonB
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
projects Project[]
learningResources LearningResource[]
partners Partner[]
specialAwards SpecialAward[]
taggingJobs TaggingJob[]
wizardTemplates WizardTemplate[]
mentorMilestones MentorMilestone[]
competitions Competition[]
@@unique([name, year])
@@index([status])
}
enum ProgramStatus {
DRAFT
ACTIVE
ARCHIVED
}
Jury & Assignment Models
JuryGroup
model JuryGroup {
id String @id @default(cuid())
competitionId String
name String // "Jury 1", "Jury 2", "Jury 3", "Innovation Award Jury"
slug String // "jury-1", "jury-2"
description String? @db.Text
sortOrder Int @default(0)
// Default assignment configuration for this jury
defaultMaxAssignments Int @default(20)
defaultCapMode CapMode @default(SOFT)
softCapBuffer Int @default(2) // Extra assignments above cap for load balancing
// Default category quotas (per juror)
categoryQuotasEnabled Boolean @default(false)
defaultCategoryQuotas Json? @db.JsonB // { "STARTUP": { "min": 2, "max": 15 }, "BUSINESS_CONCEPT": { "min": 2, "max": 15 } }
// Onboarding: can jurors adjust their own cap/ratio during onboarding?
allowJurorCapAdjustment Boolean @default(false)
allowJurorRatioAdjustment Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
competition Competition @relation(fields: [competitionId], references: [id], onDelete: Cascade)
members JuryGroupMember[]
rounds Round[] // Rounds this jury is assigned to
assignments Assignment[] // Assignments made through this jury group
awards SpecialAward[] // Awards using this jury
@@unique([competitionId, slug])
@@index([competitionId])
}
enum CapMode {
HARD // Absolute maximum — AI/algorithm cannot exceed
SOFT // Target maximum — can exceed by softCapBuffer for load balancing
NONE // No cap — unlimited assignments
}
JuryGroupMember
model JuryGroupMember {
id String @id @default(cuid())
juryGroupId String
userId String
role JuryGroupMemberRole @default(MEMBER) // CHAIR (lead), MEMBER, OBSERVER (view-only)
joinedAt DateTime @default(now())
// Per-juror overrides (null = use group defaults)
maxAssignmentsOverride Int?
capModeOverride CapMode?
categoryQuotasOverride Json? @db.JsonB // Same shape as JuryGroup.defaultCategoryQuotas
// Juror preferences (set during onboarding)
preferredStartupRatio Float? // 0.0 to 1.0 — desired % of startups (e.g., 0.6 = 60% startups)
availabilityNotes String? @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
juryGroup JuryGroup @relation(fields: [juryGroupId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
assignmentIntents AssignmentIntent[]
deliberationVotes DeliberationVote[]
deliberationParticipations DeliberationParticipant[]
@@unique([juryGroupId, userId])
@@index([juryGroupId])
@@index([userId])
}
Assignment (modified)
model Assignment {
id String @id @default(cuid())
userId String
projectId String
roundId String
// Assignment info
method AssignmentMethod @default(MANUAL)
isRequired Boolean @default(true)
isCompleted Boolean @default(false)
// AI assignment metadata
aiConfidenceScore Float?
expertiseMatchScore Float?
aiReasoning String? @db.Text
// Jury group link
juryGroupId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
juryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull)
evaluation Evaluation?
conflictOfInterest ConflictOfInterest?
exceptions AssignmentException[]
@@unique([userId, projectId, roundId])
@@index([userId])
@@index([projectId])
@@index([roundId])
@@index([juryGroupId])
@@index([isCompleted])
}
enum AssignmentMethod {
MANUAL
BULK
AI_SUGGESTED
AI_AUTO
ALGORITHM
}
AssignmentIntent
model AssignmentIntent {
id String @id @default(cuid())
juryGroupMemberId String
roundId String
projectId String
source AssignmentIntentSource // How this intent was created
status AssignmentIntentStatus @default(PENDING) // Lifecycle: PENDING → HONORED/OVERRIDDEN/EXPIRED
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
juryGroupMember JuryGroupMember @relation(fields: [juryGroupMemberId], references: [id], onDelete: Cascade)
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@unique([juryGroupMemberId, roundId, projectId])
@@index([roundId])
@@index([projectId])
@@index([status])
}
enum AssignmentIntentSource {
INVITE // Created during member invite (pre-assignment)
ADMIN // Admin-created intent
SYSTEM // System-generated (e.g., from algorithm)
}
enum AssignmentIntentStatus {
PENDING // Created, awaiting assignment algorithm execution
HONORED // Assignment algorithm materialized this intent into an Assignment record
OVERRIDDEN // Admin changed the assignment, superseding this intent
EXPIRED // Round completed without this intent being honored
CANCELLED // Explicitly cancelled by admin or system
}
AssignmentException
model AssignmentException {
id String @id @default(cuid())
assignmentId String
reason String @db.Text // Why exception was granted
overCapBy Int // How many assignments over cap
approvedById String
createdAt DateTime @default(now())
// Relations
assignment Assignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade)
approvedBy User @relation("AssignmentExceptionApprover", fields: [approvedById], references: [id])
@@index([assignmentId])
@@index([approvedById])
}
Submission & Document Models
SubmissionWindow
model SubmissionWindow {
id String @id @default(cuid())
competitionId String
name String // "Round 1 Application Docs", "Semi-finalist Additional Docs"
slug String // "round-1-docs"
roundNumber Int // 1, 2, 3... (sequential)
sortOrder Int @default(0)
// Window timing
windowOpenAt DateTime?
windowCloseAt DateTime?
// Deadline behavior
deadlinePolicy DeadlinePolicy @default(FLAG)
graceHours Int? // Hours after windowCloseAt where late submissions accepted
// Locking behavior
lockOnClose Boolean @default(true) // Applicants can't edit after window closes
isLocked Boolean @default(false) // Manual lock toggle — admin can lock/unlock independently of window close
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
competition Competition @relation(fields: [competitionId], references: [id], onDelete: Cascade)
fileRequirements SubmissionFileRequirement[]
projectFiles ProjectFile[]
rounds Round[] // Rounds that collect submissions for this window
visibility RoundSubmissionVisibility[] // Which evaluation rounds can see these docs
@@unique([competitionId, slug])
@@unique([competitionId, roundNumber])
@@index([competitionId])
}
enum DeadlinePolicy {
HARD // Submissions rejected after close
FLAG // Submissions accepted but marked late
GRACE // Grace period after close, then hard cutoff
}
SubmissionFileRequirement
model SubmissionFileRequirement {
id String @id @default(cuid())
submissionWindowId String
name String // "Executive Summary", "Business Plan", "Video Pitch"
description String? @db.Text
acceptedMimeTypes String[] // ["application/pdf", "video/*"]
maxSizeMB Int? // Size limit
isRequired Boolean @default(true)
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
submissionWindow SubmissionWindow @relation(fields: [submissionWindowId], references: [id], onDelete: Cascade)
files ProjectFile[] // Files uploaded against this requirement
@@index([submissionWindowId])
}
RoundSubmissionVisibility
model RoundSubmissionVisibility {
id String @id @default(cuid())
roundId String
submissionWindowId String
canView Boolean @default(true) // Jury can see these docs
displayLabel String? // "Round 1 Docs", "Round 2 Docs" (shown to jury)
// Relations
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
submissionWindow SubmissionWindow @relation(fields: [submissionWindowId], references: [id], onDelete: Cascade)
@@unique([roundId, submissionWindowId])
@@index([roundId])
}
ProjectFile (modified)
model ProjectFile {
id String @id @default(cuid())
projectId String
// Link to SubmissionWindow instead of legacy roundId
submissionWindowId String?
requirementId String? // Links to SubmissionFileRequirement
fileType FileType
fileName String
mimeType String
size Int
bucket String
objectKey String
isLate Boolean @default(false)
version Int @default(1)
replacedById String? @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
submissionWindow SubmissionWindow? @relation(fields: [submissionWindowId], references: [id], onDelete: SetNull)
requirement SubmissionFileRequirement? @relation(fields: [requirementId], references: [id], onDelete: SetNull)
replacedBy ProjectFile? @relation("FileVersion", fields: [replacedById], references: [id])
previousVersion ProjectFile? @relation("FileVersion")
promotedFrom MentorFile? @relation("PromotedFromMentorFile")
@@index([projectId])
@@index([submissionWindowId])
@@index([requirementId])
}
enum FileType {
EXEC_SUMMARY
PRESENTATION
VIDEO
OTHER
BUSINESS_PLAN
VIDEO_PITCH
SUPPORTING_DOC
}
MentorFile (NEW)
model MentorFile {
id String @id @default(cuid())
mentorAssignmentId String
uploadedByUserId String
fileName String
mimeType String
size Int
bucket String
objectKey String
description String? @db.Text
// Promotion to official submission
isPromoted Boolean @default(false)
promotedToFileId String? @unique // Links to the ProjectFile created on promotion
promotedAt DateTime?
promotedByUserId String?
createdAt DateTime @default(now())
// Relations
mentorAssignment MentorAssignment @relation(fields: [mentorAssignmentId], references: [id], onDelete: Cascade)
uploadedBy User @relation("MentorFileUploader", fields: [uploadedByUserId], references: [id])
promotedBy User? @relation("MentorFilePromoter", fields: [promotedByUserId], references: [id])
promotedFile ProjectFile? @relation("PromotedFromMentorFile", fields: [promotedToFileId], references: [id], onDelete: SetNull)
comments MentorFileComment[]
promotionEvents SubmissionPromotionEvent[]
@@index([mentorAssignmentId])
@@index([uploadedByUserId])
}
MentorFileComment (NEW)
model MentorFileComment {
id String @id @default(cuid())
mentorFileId String
authorId String
content String @db.Text
// Threading support
parentCommentId String? // null = top-level comment, non-null = reply
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
mentorFile MentorFile @relation(fields: [mentorFileId], references: [id], onDelete: Cascade)
author User @relation("MentorFileCommentAuthor", fields: [authorId], references: [id])
parentComment MentorFileComment? @relation("CommentThread", fields: [parentCommentId], references: [id], onDelete: Cascade)
replies MentorFileComment[] @relation("CommentThread")
@@index([mentorFileId])
@@index([authorId])
@@index([parentCommentId])
}
MentorMessage (NEW)
model MentorMessage {
id String @id @default(cuid())
workspaceId String // Links to MentorAssignment
senderId String
senderRole MentorMessageRole // MENTOR | APPLICANT | ADMIN
content String @db.Text
createdAt DateTime @default(now())
// Relations
workspace MentorAssignment @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
sender User @relation("MentorMessageSender", fields: [senderId], references: [id])
@@index([workspaceId])
@@index([senderId])
@@index([createdAt])
}
enum MentorMessageRole {
MENTOR
APPLICANT
ADMIN
}
SubmissionPromotionEvent (NEW)
model SubmissionPromotionEvent {
id String @id @default(cuid())
projectId String
roundId String
slotKey String // Which requirement slot this promoted file fills
sourceType SubmissionPromotionSource
sourceFileId String? // MentorFile ID if promoted from mentoring workspace
promotedById String
createdAt DateTime @default(now())
// Relations
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
sourceFile MentorFile? @relation(fields: [sourceFileId], references: [id], onDelete: SetNull)
promotedBy User @relation("SubmissionPromoter", fields: [promotedById], references: [id])
@@index([projectId])
@@index([roundId])
@@index([sourceFileId])
}
enum SubmissionPromotionSource {
MENTOR_FILE // Promoted from mentoring workspace
ADMIN_REPLACEMENT // Admin directly replaced official submission
}
MentorAssignment (modified)
model MentorAssignment {
id String @id @default(cuid())
projectId String @unique // One mentor per project
mentorId String // User with MENTOR role or expertise
// Assignment tracking
method AssignmentMethod @default(MANUAL)
assignedAt DateTime @default(now())
assignedBy String?
// AI assignment metadata
aiConfidenceScore Float?
expertiseMatchScore Float?
aiReasoning String? @db.Text
// Tracking
completionStatus String @default("in_progress") // 'in_progress' | 'completed' | 'paused'
lastViewedAt DateTime?
// Workspace activation
workspaceEnabled Boolean @default(false) // Activated when MENTORING round opens
workspaceOpenAt DateTime? // When mentoring files/chat becomes available
workspaceCloseAt DateTime? // When workspace access ends
// Relations
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
mentor User @relation("MentorAssignment", fields: [mentorId], references: [id])
notes MentorNote[]
milestoneCompletions MentorMilestoneCompletion[]
messages MentorMessage[]
files MentorFile[]
@@index([mentorId])
@@index([method])
}
Evaluation & Scoring Models
Evaluation
model Evaluation {
id String @id @default(cuid())
assignmentId String @unique
formId String
status EvaluationStatus @default(NOT_STARTED)
// Scores
criterionScoresJson Json? @db.JsonB
globalScore Int?
binaryDecision Boolean?
feedbackText String? @db.Text
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
submittedAt DateTime?
// Relations
assignment Assignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade)
form EvaluationForm @relation(fields: [formId], references: [id], onDelete: Cascade)
@@index([status])
@@index([submittedAt])
@@index([formId])
}
enum EvaluationStatus {
NOT_STARTED
DRAFT
SUBMITTED
LOCKED
}
EvaluationForm
model EvaluationForm {
id String @id @default(cuid())
roundId String
version Int @default(1)
// Form configuration
criteriaJson Json @db.JsonB // Array of { id, label, description, scale, weight, required }
scalesJson Json? @db.JsonB // { "1-5": { min, max, labels }, "1-10": { min, max, labels } }
isActive Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
evaluations Evaluation[]
@@unique([roundId, version])
@@index([roundId, isActive])
}
ConflictOfInterest
model ConflictOfInterest {
id String @id @default(cuid())
assignmentId String @unique
userId String
projectId String
hasConflict Boolean @default(false)
conflictType String? // "financial", "personal", "organizational", "other"
description String? @db.Text
declaredAt DateTime @default(now())
// Admin review
reviewedById String?
reviewedAt DateTime?
reviewAction String? // "cleared", "reassigned", "noted"
// Relations
assignment Assignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id])
reviewedBy User? @relation("COIReviewedBy", fields: [reviewedById], references: [id])
@@index([userId])
@@index([hasConflict])
}
EvaluationSummary
model EvaluationSummary {
id String @id @default(cuid())
projectId String
roundId String
summaryJson Json @db.JsonB
generatedAt DateTime @default(now())
generatedById String
model String
tokensUsed Int
// Relations
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
generatedBy User @relation("EvaluationSummaryGeneratedBy", fields: [generatedById], references: [id])
@@unique([projectId, roundId])
@@index([roundId])
}
Deliberation Models (NEW - replaces WinnerProposal/WinnerApproval)
DeliberationSession
model DeliberationSession {
id String @id @default(cuid())
competitionId String
roundId String
category ProjectCategory
mode DeliberationMode // SINGLE_WINNER_VOTE | FULL_RANKING
showCollectiveRankings Boolean @default(false)
showPriorJuryData Boolean @default(false)
status DeliberationStatus // OPEN | VOTING | TALLYING | RUNOFF | LOCKED
tieBreakMethod TieBreakMethod // RUNOFF | ADMIN_DECIDES | SCORE_FALLBACK
adminOverrideResult Json? @db.JsonB
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
competition Competition @relation(fields: [competitionId], references: [id], onDelete: Cascade)
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
votes DeliberationVote[]
results DeliberationResult[]
participants DeliberationParticipant[]
@@index([competitionId])
@@index([roundId])
@@index([status])
}
enum DeliberationMode {
SINGLE_WINNER_VOTE // Each jury member votes for one winner
FULL_RANKING // Each jury member ranks all projects
}
enum DeliberationStatus {
OPEN // Session created, waiting for votes
VOTING // Active voting window
TALLYING // Counting votes
RUNOFF // Tie detected, runoff voting in progress
LOCKED // Results finalized, no more changes
}
enum TieBreakMethod {
RUNOFF // Create new voting round for tied projects
ADMIN_DECIDES // Admin manually breaks tie
SCORE_FALLBACK // Use previous round scores to break tie
}
enum ProjectCategory {
STARTUP // Existing companies
BUSINESS_CONCEPT // Students/graduates
}
DeliberationVote
model DeliberationVote {
id String @id @default(cuid())
sessionId String
juryMemberId String
projectId String
rank Int? // ordinal rank in FULL_RANKING mode
isWinnerPick Boolean @default(false) // true in SINGLE_WINNER_VOTE mode
runoffRound Int @default(0) // 0 = initial, 1+ = runoff rounds
createdAt DateTime @default(now())
// Relations
session DeliberationSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
juryMember JuryGroupMember @relation(fields: [juryMemberId], references: [id], onDelete: Cascade)
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@unique([sessionId, juryMemberId, projectId, runoffRound])
@@index([sessionId])
@@index([juryMemberId])
@@index([projectId])
}
DeliberationResult
model DeliberationResult {
id String @id @default(cuid())
sessionId String
projectId String
finalRank Int
voteCount Int @default(0)
isAdminOverridden Boolean @default(false)
overrideReason String? @db.Text
// Relations
session DeliberationSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@unique([sessionId, projectId])
@@index([sessionId])
@@index([projectId])
}
DeliberationParticipant
model DeliberationParticipant {
id String @id @default(cuid())
sessionId String
userId String
status DeliberationParticipantStatus // REQUIRED | ABSENT_EXCUSED | REPLACED | REPLACEMENT_ACTIVE
replacedById String?
// Relations
session DeliberationSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
user JuryGroupMember @relation(fields: [userId], references: [id], onDelete: Cascade)
replacedBy User? @relation("DeliberationReplacement", fields: [replacedById], references: [id])
@@unique([sessionId, userId])
@@index([sessionId])
@@index([userId])
}
enum DeliberationParticipantStatus {
REQUIRED // Expected to participate
ABSENT_EXCUSED // Absent with permission
REPLACED // Original member replaced
REPLACEMENT_ACTIVE // Acting as replacement
}
Result & Audit Models
ResultLock (NEW)
model ResultLock {
id String @id @default(cuid())
competitionId String
roundId String
category ProjectCategory
lockedById String
resultSnapshot Json @db.JsonB // Frozen result data
lockedAt DateTime @default(now())
// Relations
competition Competition @relation(fields: [competitionId], references: [id], onDelete: Cascade)
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
lockedBy User @relation("ResultLockCreator", fields: [lockedById], references: [id])
unlockEvents ResultUnlockEvent[]
@@index([competitionId])
@@index([roundId])
@@index([category])
}
ResultUnlockEvent (NEW)
model ResultUnlockEvent {
id String @id @default(cuid())
resultLockId String
unlockedById String
reason String @db.Text
unlockedAt DateTime @default(now())
// Relations
resultLock ResultLock @relation(fields: [resultLockId], references: [id], onDelete: Cascade)
unlockedBy User @relation("ResultUnlocker", fields: [unlockedById], references: [id])
@@index([resultLockId])
@@index([unlockedById])
}
DecisionAuditLog
model DecisionAuditLog {
id String @id @default(cuid())
eventType String // round.transitioned, routing.executed, filtering.completed, etc.
entityType String
entityId String
actorId String?
detailsJson Json? @db.JsonB
snapshotJson Json? @db.JsonB // State at time of decision
createdAt DateTime @default(now())
@@index([eventType])
@@index([entityType, entityId])
@@index([actorId])
@@index([createdAt])
}
Audit detailsJson convention: All admin override events MUST include beforeState and afterState fields for diff tracking:
// Example: admin overrides eligibility
{
action: "override_eligibility",
beforeState: { eligible: false, aiConfidence: 0.35 },
afterState: { eligible: true },
reason: "Project meets criteria despite low AI confidence — manual review confirms eligibility"
}
// Example: admin overrides assignment
{
action: "override_assignment",
beforeState: { assignedToUserId: "user-a", method: "ALGORITHM" },
afterState: { assignedToUserId: "user-b", method: "MANUAL" },
reason: "Reassigned due to scheduling conflict"
}
This convention enables:
- Automated diff reports for compliance
- Undo/revert capability for admin overrides
- Before/after comparison in audit UI
Special Award Models
SpecialAward (modified)
model SpecialAward {
id String @id @default(cuid())
competitionId String // Links to Competition
name String
description String? @db.Text
criteriaText String? @db.Text // For AI eligibility
// Award mode
eligibilityMode AwardEligibilityMode @default(STAY_IN_MAIN)
// Scoring/voting
scoringMode AwardScoringMode @default(PICK_WINNER)
maxRankedPicks Int? // For RANKED mode
// Decision
decisionMode String @default("JURY_VOTE") // "JURY_VOTE" | "AWARD_MASTER_DECISION" | "ADMIN_DECISION"
// Status
status AwardStatus @default(DRAFT)
// Voting window
votingStartAt DateTime?
votingEndAt DateTime?
// Runs alongside which evaluation round
evaluationRoundId String?
// Jury (can be its own group or share a competition jury group)
juryGroupId String?
// Winner
winnerProjectId String?
winnerOverridden Boolean @default(false)
// AI
useAiEligibility Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
competition Competition @relation(fields: [competitionId], references: [id], onDelete: Cascade)
evaluationRound Round? @relation(fields: [evaluationRoundId], references: [id], onDelete: SetNull)
juryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull)
winnerProject Project? @relation("AwardWinner", fields: [winnerProjectId], references: [id], onDelete: SetNull)
eligibilities AwardEligibility[]
jurors AwardJuror[]
votes AwardVote[]
@@index([competitionId])
@@index([status])
@@index([evaluationRoundId])
}
enum AwardEligibilityMode {
SEPARATE_POOL // Projects pulled out of main flow into award-only track
STAY_IN_MAIN // Projects remain in main competition, flagged as award-eligible
}
enum AwardStatus {
DRAFT
NOMINATIONS_OPEN
VOTING_OPEN
CLOSED
ARCHIVED
}
enum AwardScoringMode {
PICK_WINNER
RANKED
SCORED
}
AwardEligibility
model AwardEligibility {
id String @id @default(cuid())
awardId String
projectId String
method EligibilityMethod @default(AUTO)
eligible Boolean @default(false)
aiReasoningJson Json? @db.JsonB
// Admin override
overriddenBy String?
overriddenAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
award SpecialAward @relation(fields: [awardId], references: [id], onDelete: Cascade)
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
overriddenByUser User? @relation("AwardEligibilityOverriddenBy", fields: [overriddenBy], references: [id], onDelete: SetNull)
@@unique([awardId, projectId])
@@index([awardId])
@@index([projectId])
@@index([eligible])
}
enum EligibilityMethod {
AUTO
MANUAL
}
AwardJuror
model AwardJuror {
id String @id @default(cuid())
awardId String
userId String
createdAt DateTime @default(now())
// Relations
award SpecialAward @relation(fields: [awardId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([awardId, userId])
@@index([awardId])
@@index([userId])
}
AwardVote
model AwardVote {
id String @id @default(cuid())
awardId String
userId String
projectId String
rank Int? // For RANKED mode
votedAt DateTime @default(now())
// Relations
award SpecialAward @relation(fields: [awardId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@unique([awardId, userId, projectId])
@@index([awardId])
@@index([userId])
@@index([projectId])
}
Policy & Override Models
5-Layer Policy Precedence
Policy resolution follows this precedence order (highest to lowest):
| Layer | Description | Example Use Case |
|---|---|---|
| 1. Admin Override | Explicit override action with mandatory reason | Admin grants exception to assign 25 projects to a jury member with cap of 20 |
| 2. Per-Member Override | Individual jury member policy settings | Jury member sets personal cap to 15 instead of group default 20 |
| 3. Jury Group Default | JuryGroup-level configuration | Jury 1 has defaultMaxAssignments=20, softCapBuffer=2 |
| 4. Program Default | Program-wide settings in Program.settingsJson | All competitions in MOPC 2026 default to 3 finalists per category |
| 5. System Default | Platform-wide hardcoded defaults | Default CapMode is SOFT if not specified |
Resolution Logic:
- Each policy query returns:
{ value, source: "admin_override" | "member" | "jury_group" | "program" | "system", explanation: string } - UI shows policy source and allows drilling into why a value was chosen
- All policy evaluations are logged in DecisionAuditLog
Policy Models
Policy configuration is embedded in existing models:
System Defaults → Hardcoded in application config
Program Defaults → Program.settingsJson
Jury Group Defaults → JuryGroup.defaultMaxAssignments, JuryGroup.defaultCapMode, etc.
Per-Member Overrides → JuryGroupMember.maxAssignmentsOverride, JuryGroupMember.capModeOverride, etc.
Admin Overrides → AssignmentException, FilteringResult.overriddenBy, AwardEligibility.overriddenBy
Enum Definitions
enum CompetitionStatus {
DRAFT
ACTIVE
CLOSED
ARCHIVED
}
enum RoundType {
INTAKE
FILTERING
EVALUATION
SUBMISSION
MENTORING
LIVE_FINAL
DELIBERATION
}
enum RoundStatus {
ROUND_DRAFT
ROUND_ACTIVE
ROUND_CLOSED
ROUND_ARCHIVED
}
enum ProjectRoundStateValue {
PENDING
IN_PROGRESS
PASSED
REJECTED
COMPLETED
WITHDRAWN
}
enum AdvancementRuleType {
AUTO_ADVANCE
SCORE_THRESHOLD
TOP_N
ADMIN_SELECTION
AI_RECOMMENDED
}
enum CapMode {
HARD
SOFT
NONE
}
enum AssignmentMethod {
MANUAL
BULK
AI_SUGGESTED
AI_AUTO
ALGORITHM
}
enum AssignmentIntentSource {
INVITE
ADMIN
SYSTEM
}
enum AssignmentIntentStatus {
PENDING
HONORED
OVERRIDDEN
EXPIRED
CANCELLED
}
enum JuryGroupMemberRole {
CHAIR // Jury lead — can manage session, see aggregate data
MEMBER // Regular juror — votes, scores, provides feedback
OBSERVER // View-only — can see evaluations/deliberations but cannot vote
}
enum DeadlinePolicy {
HARD
FLAG
GRACE
}
enum FileType {
EXEC_SUMMARY
PRESENTATION
VIDEO
OTHER
BUSINESS_PLAN
VIDEO_PITCH
SUPPORTING_DOC
}
enum MentorMessageRole {
MENTOR
APPLICANT
ADMIN
}
enum SubmissionPromotionSource {
MENTOR_FILE
ADMIN_REPLACEMENT
}
enum EvaluationStatus {
NOT_STARTED
DRAFT
SUBMITTED
LOCKED
}
enum DeliberationMode {
SINGLE_WINNER_VOTE
FULL_RANKING
}
enum DeliberationStatus {
OPEN
VOTING
TALLYING
RUNOFF
LOCKED
}
enum TieBreakMethod {
RUNOFF
ADMIN_DECIDES
SCORE_FALLBACK
}
enum DeliberationParticipantStatus {
REQUIRED
ABSENT_EXCUSED
REPLACED
REPLACEMENT_ACTIVE
}
enum ProjectCategory {
STARTUP
BUSINESS_CONCEPT
}
enum AwardEligibilityMode {
SEPARATE_POOL
STAY_IN_MAIN
}
enum AwardStatus {
DRAFT
NOMINATIONS_OPEN
VOTING_OPEN
CLOSED
ARCHIVED
}
enum AwardScoringMode {
PICK_WINNER
RANKED
SCORED
}
enum EligibilityMethod {
AUTO
MANUAL
}
Typed Config Schemas
Each RoundType has a specific Zod-validated config schema stored in Round.configJson.
1. IntakeConfig
import { z } from 'zod';
export const IntakeConfigSchema = z.object({
// Submission behavior
allowDrafts: z.boolean().default(true),
draftExpiryDays: z.number().int().positive().default(30),
// Accepted categories
acceptedCategories: z.array(z.enum(['STARTUP', 'BUSINESS_CONCEPT'])).default(['STARTUP', 'BUSINESS_CONCEPT']),
// File constraints
maxFileSize: z.number().int().positive().default(50), // MB per file
allowedFileTypes: z.array(z.string()).default(['application/pdf']),
maxFilesPerSlot: z.number().int().positive().default(1),
// Late submission notification
lateSubmissionNotification: z.boolean().default(true), // Notify admin when late submissions arrive
// File constraints (applied to all slots in this intake window)
maxFileSizeMB: z.number().int().positive().default(50),
maxFilesPerSlot: z.number().int().positive().default(1),
allowedMimeTypes: z.array(z.string()).default(['application/pdf']),
// Late submission notification
lateSubmissionNotification: z.boolean().default(true), // Notify admin when late submissions arrive (FLAG policy)
// Public form settings
publicFormEnabled: z.boolean().default(false),
customFields: z.array(z.object({
id: z.string(),
label: z.string(),
type: z.enum(['text', 'textarea', 'select', 'checkbox', 'date']),
required: z.boolean().default(false),
options: z.array(z.string()).optional(),
})).default([]),
});
export type IntakeConfig = z.infer<typeof IntakeConfigSchema>;
2. FilteringConfig
export const FilteringConfigSchema = z.object({
// Rule engine
rules: z.array(z.object({
id: z.string(),
name: z.string(),
ruleType: z.enum(['FIELD_BASED', 'DOCUMENT_CHECK']),
conditions: z.any(), // Rule-specific conditions
action: z.enum(['PASS', 'REJECT', 'FLAG']),
})).default([]),
// AI screening
aiScreeningEnabled: z.boolean().default(true),
aiCriteriaText: z.string().optional(),
aiConfidenceThresholds: z.object({
high: z.number().min(0).max(1).default(0.85), // Auto-pass
medium: z.number().min(0).max(1).default(0.6), // Flag for review
low: z.number().min(0).max(1).default(0.4), // Auto-reject
}).default({
high: 0.85,
medium: 0.6,
low: 0.4,
}),
// Manual queue
manualReviewEnabled: z.boolean().default(true),
// Auto-advancement
autoAdvanceEligible: z.boolean().default(false), // Passing projects auto-advance without admin review
// Duplicate detection
duplicateDetectionEnabled: z.boolean().default(true), // Flag potential duplicate submissions
// Auto-advance eligible projects (skip admin review for clear passes)
autoAdvanceEligible: z.boolean().default(false),
// Duplicate detection
duplicateDetectionEnabled: z.boolean().default(true),
// Batch processing
batchSize: z.number().int().positive().default(20),
});
export type FilteringConfig = z.infer<typeof FilteringConfigSchema>;
3. EvaluationConfig
export const EvaluationConfigSchema = z.object({
// Assignment settings
requiredReviewsPerProject: z.number().int().positive().default(3),
// Scoring
scoringMode: z.enum(['criteria', 'global', 'binary']).default('criteria'),
requireFeedback: z.boolean().default(true),
feedbackMinLength: z.number().int().nonnegative().default(0), // 0 = no minimum
requireAllCriteriaScored: z.boolean().default(true), // Block submission if any criterion is blank
// COI
coiRequired: z.boolean().default(true),
// Feedback
feedbackMinLength: z.number().int().nonnegative().default(0), // 0 = no minimum
requireAllCriteriaScored: z.boolean().default(true),
// Peer review
peerReviewEnabled: z.boolean().default(false),
anonymizationLevel: z.enum(['fully_anonymous', 'show_initials', 'named']).default('fully_anonymous'),
// AI features
aiSummaryEnabled: z.boolean().default(false),
generateAiShortlist: z.boolean().default(true), // AI-ranked shortlist per category at round end
generateAiShortlist: z.boolean().default(false), // Generate AI-ranked shortlist at round end
// Advancement
advancementMode: z.enum(['auto_top_n', 'admin_selection', 'ai_recommended']).default('admin_selection'),
advancementConfig: z.object({
perCategory: z.boolean().default(true),
startupCount: z.number().int().nonnegative().default(10),
conceptCount: z.number().int().nonnegative().default(10),
tieBreaker: z.enum(['admin_decides', 'highest_individual', 'revote']).default('admin_decides'),
}).optional(),
});
export type EvaluationConfig = z.infer<typeof EvaluationConfigSchema>;
4. SubmissionConfig
export const SubmissionConfigSchema = z.object({
// Who can submit
eligibleStatuses: z.array(z.enum(['PENDING', 'IN_PROGRESS', 'PASSED', 'REJECTED', 'COMPLETED', 'WITHDRAWN'])).default(['PASSED']),
// Notification
notifyEligibleTeams: z.boolean().default(true),
// Lock previous rounds
lockPreviousWindows: z.boolean().default(true),
});
export type SubmissionConfig = z.infer<typeof SubmissionConfigSchema>;
5. MentoringConfig
export const MentoringConfigSchema = z.object({
// Eligibility
eligibility: z.enum(['all_advancing', 'requested_only']).default('requested_only'),
// Workspace features
chatEnabled: z.boolean().default(true),
fileUploadEnabled: z.boolean().default(true),
fileCommentsEnabled: z.boolean().default(true),
filePromotionEnabled: z.boolean().default(true),
// Promotion target
promotionTargetWindowId: z.string().optional(),
// Auto-assignment
autoAssignMentors: z.boolean().default(false),
});
export type MentoringConfig = z.infer<typeof MentoringConfigSchema>;
6. LiveFinalConfig
export const LiveFinalConfigSchema = z.object({
// Jury voting
juryVotingEnabled: z.boolean().default(true),
votingMode: z.enum(['simple', 'criteria']).default('simple'),
// Audience voting
audienceVotingEnabled: z.boolean().default(false),
audienceVoteWeight: z.number().min(0).max(1).default(0),
audienceVotingMode: z.enum(['per_project', 'per_category', 'favorites']).default('per_project'),
audienceMaxFavorites: z.number().int().positive().default(3),
audienceRequireIdentification: z.boolean().default(false),
// Deliberation
deliberationEnabled: z.boolean().default(false),
deliberationDurationMinutes: z.number().int().positive().default(30),
showAudienceVotesToJury: z.boolean().default(false),
// Presentation
presentationOrderMode: z.enum(['manual', 'random', 'score_based']).default('manual'),
presentationDurationMinutes: z.number().int().positive().default(15), // Per-project presentation time
qaDurationMinutes: z.number().int().nonnegative().default(5), // Per-project Q&A time (0 = no Q&A)
// Results reveal
revealPolicy: z.enum(['immediate', 'delayed', 'ceremony']).default('ceremony'),
});
export type LiveFinalConfig = z.infer<typeof LiveFinalConfigSchema>;
7. DeliberationConfig
export const DeliberationConfigSchema = z.object({
// Jury group
juryGroupId: z.string(),
// Mode
mode: z.enum(['SINGLE_WINNER_VOTE', 'FULL_RANKING']).default('SINGLE_WINNER_VOTE'),
// Display
showCollectiveRankings: z.boolean().default(false),
showPriorJuryData: z.boolean().default(false), // Show Jury 1/2 scores/feedback to deliberation jury
// Tie handling
tieBreakMethod: z.enum(['RUNOFF', 'ADMIN_DECIDES', 'SCORE_FALLBACK']).default('ADMIN_DECIDES'),
// Timing
votingDuration: z.number().int().positive().default(60), // minutes
// Winner count
topN: z.number().int().positive().default(3), // How many winners per category (podium size)
// Admin control
allowAdminOverride: z.boolean().default(true),
});
export type DeliberationConfig = z.infer<typeof DeliberationConfigSchema>;
Migration Notes
New Models Added
- Competition — Replaces Pipeline
- Round — Replaces Stage
- JuryGroup, JuryGroupMember — New jury management system
- SubmissionWindow, SubmissionFileRequirement, RoundSubmissionVisibility — Multi-round document submission
- MentorFile, MentorFileComment, MentorMessage — Mentoring workspace
- DeliberationSession, DeliberationVote, DeliberationResult, DeliberationParticipant — Winner confirmation
- ResultLock, ResultUnlockEvent — Result immutability
- AssignmentIntent, AssignmentException — Assignment governance
- SubmissionPromotionEvent — Mentoring file promotion tracking
- AdvancementRule — Replaces StageTransition
Renamed Models
Pipeline→CompetitionStage→RoundStageType→RoundTypeStageStatus→RoundStatusProjectStageState→ProjectRoundStateProjectStageStateValue→ProjectRoundStateValue
Removed Models
- Track — Eliminated (main flow is linear; awards are SpecialAward entities)
- StageTransition — Replaced by AdvancementRule
- WinnerProposal, WinnerApproval — Replaced by Deliberation system
New Enums Added
JuryGroupMemberRole(CHAIR, MEMBER, OBSERVER) — replaces booleanisLeadAssignmentIntentStatus(PENDING, HONORED, OVERRIDDEN, EXPIRED, CANCELLED) — replaces String status
Modified Models
- Assignment — Added
juryGroupIdlink - Round — Added
purposeKey(optional analytics tag) - JuryGroupMember — Changed
isLead: Booleantorole: JuryGroupMemberRole - AssignmentIntent — Changed
status: Stringtostatus: AssignmentIntentStatus(proper enum with lifecycle) - SubmissionWindow — Added
isLocked: Boolean(manual lock override independent of window close) - ProjectFile — Changed
roundIdtosubmissionWindowId, addedrequirementId, addedreplacedByIdself-reference - SpecialAward — Added
competitionId,evaluationRoundId,juryGroupId - MentorAssignment — Added workspace fields (
workspaceEnabled,workspaceOpenAt, etc.) - Project — Added
competitionId(direct link to competition) - DecisionAuditLog — Documented
beforeState/afterStateconvention indetailsJson
Field Renames
All stageId foreign keys → roundId
All trackId references → Removed
Cross-References
- See 03-competition-flow.md for end-to-end competition orchestration
- See 04-jury-system.md for jury management and assignment logic
- See 05-submission-system.md for multi-round document handling
- See 06-mentoring-workspace.md for mentoring file collaboration
- See 07-deliberation-flow.md for winner confirmation process
- See 08-result-locking.md for result immutability and unlock audit