53 KiB
Round: Confirmation — Winner Agreement & Result Freezing
Overview
The CONFIRMATION round (Round 8 in the standard 8-step flow) is the final step of the competition. It transforms live-finals scores into official, immutable results through a multi-party digital agreement process.
Purpose
After Jury 3 completes live scoring in Round 7 (LIVE_FINAL), the platform generates a WinnerProposal — a ranked list of projects per category. This proposal must be ratified by the jury members who scored it, confirmed by the admin, and then frozen so that official results cannot be tampered with.
Key Principles
| Principle | Implementation |
|---|---|
| Unanimous by default | Every jury member on the finals jury must individually approve |
| Admin override available | Admin can force-majority or outright select winners |
| Immutable once frozen | Frozen results cannot be changed — new proposal required |
| Full audit trail | Every approval, rejection, override, and freeze is logged |
| Per-category | Startups and Concepts can be confirmed independently |
| Special awards included | Award winners are part of the confirmation package |
Flow Summary
Live Finals Complete
│
▼
┌─────────────────────────────┐
│ WinnerProposal Generated │ ← Auto-generated from scores, or admin-created
│ Status: PENDING │
└─────────────┬───────────────┘
│
▼
┌─────────────────────────────┐
│ Jury Approval Requests │ ← Each jury member gets notification
│ Sent to all Jury 3 members │
└─────────────┬───────────────┘
│
┌─────────┼──────────┐
▼ ▼ ▼
Juror A Juror B Juror C ... each reviews & votes
APPROVE APPROVE REJECT
│ │ │
└─────────┼──────────┘
│
▼
┌───────────┐
│ All voted? │
└─────┬─────┘
│
┌────────┴────────┐
▼ ▼
Unanimous? Not Unanimous
│ │
▼ ▼
Status: Admin Override
APPROVED Decision Point
│ │
▼ ┌────┴──────┐
Auto-freeze │ │
(if enabled) ▼ ▼
FORCE_MAJORITY ADMIN_DECISION
(Accept if (Admin picks
majority winners
approved) directly)
│ │
▼ ▼
Status: Status:
OVERRIDDEN OVERRIDDEN
│ │
└──────┬───────┘
▼
Admin Freeze
(manual trigger)
│
▼
Status: FROZEN
Results are official
Current System
Today, there is no confirmation step. The pipeline flow is:
INTAKE → FILTER → EVALUATION → SELECTION → LIVE_FINAL → RESULTS
The RESULTS stage type simply displays computed results from LIVE_FINAL. There is:
- No jury sign-off on results
- No admin confirmation step
- No freeze/lock mechanism
- No override system for disputed results
- No multi-party agreement
Impact: Results can theoretically be changed at any time, there's no evidence trail of who agreed to the outcome, and no formal "cementing" of winners.
Redesigned Confirmation Round
ConfirmationConfig
type ConfirmationConfig = {
// ── Approval Requirements ──────────────────────────────
requireAllJuryApproval: boolean; // true = unanimous, false = majority
juryGroupId: string | null; // Which jury must approve (default: Jury 3)
minimumApprovalThreshold?: number; // For non-unanimous: minimum % (e.g., 0.67 = 2/3)
// ── Admin Override ──────────────────────────────────────
adminOverrideEnabled: boolean; // Admin can force result
overrideModes: OverrideMode[]; // Available override options
// ── Freeze Behavior ─────────────────────────────────────
autoFreezeOnApproval: boolean; // Lock results immediately when all approve
requireExplicitFreeze: boolean; // Even after approval, admin must click "Freeze"
// ── Per-category ────────────────────────────────────────
perCategory: boolean; // Separate proposals per STARTUP vs CONCEPT
// ── Special Awards ──────────────────────────────────────
includeSpecialAwards: boolean; // Include award winners in confirmation
// ── Deadline ────────────────────────────────────────────
approvalDeadlineDays: number; // Days jury has to respond (default: 7)
reminderSchedule: number[]; // Days before deadline to remind (e.g., [3, 1])
// ── Notification ────────────────────────────────────────
notifyOnApproval: boolean; // Notify admin when each juror approves
notifyOnRejection: boolean; // Notify admin immediately on any rejection
notifyOnFreeze: boolean; // Notify all parties when results frozen
};
type OverrideMode = 'FORCE_MAJORITY' | 'ADMIN_DECISION';
Field Behavior Reference
| Field | Default | Description |
|---|---|---|
requireAllJuryApproval |
true |
When true, ALL jury members must approve. When false, only minimumApprovalThreshold % needed |
juryGroupId |
Finals jury | Links to the JuryGroup that must confirm. Usually the same jury that scored live finals |
minimumApprovalThreshold |
1.0 |
Only used when requireAllJuryApproval=false. E.g., 0.67 = two-thirds majority |
adminOverrideEnabled |
true |
Whether admin can bypass jury deadlock |
overrideModes |
['FORCE_MAJORITY', 'ADMIN_DECISION'] |
FORCE_MAJORITY: accept if >50% approved. ADMIN_DECISION: admin picks winners directly |
autoFreezeOnApproval |
true |
When all approvals received, auto-freeze results |
requireExplicitFreeze |
false |
If true, even after unanimous approval, admin must click "Freeze" to finalize |
perCategory |
true |
Separate WinnerProposal per category |
includeSpecialAwards |
true |
Include special award winners in confirmation package |
approvalDeadlineDays |
7 |
Jury has this many days to approve |
reminderSchedule |
[3, 1] |
Reminder emails N days before deadline |
WinnerProposal Lifecycle
States
enum WinnerProposalStatus {
PENDING = 'PENDING', // Waiting for jury approvals
APPROVED = 'APPROVED', // All required approvals received
REJECTED = 'REJECTED', // Failed — needs new proposal or override
OVERRIDDEN = 'OVERRIDDEN', // Admin used override power
FROZEN = 'FROZEN', // Locked — official, immutable results
}
State Transitions
┌───────────────┐
│ PENDING │◄──── Created from scores
└───────┬───────┘ or admin draft
│
┌─────────────┼─────────────┐
▼ │ ▼
All approved Some rejected Admin override
│ │ │
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌────────────┐
│ APPROVED │ │ REJECTED │ │ OVERRIDDEN │
└────┬─────┘ └────┬─────┘ └─────┬──────┘
│ │ │
│ Create new │
│ proposal or │
│ admin override │
▼ ▼
┌──────────────────────────────────┐
│ FROZEN │◄── Immutable
└──────────────────────────────────┘
Validity Rules
| Transition | From | To | Condition |
|---|---|---|---|
| Auto-approve | PENDING | APPROVED | All required approvals received |
| Reject | PENDING | REJECTED | Any jury member rejects (if unanimous required) |
| Override: Force Majority | PENDING/REJECTED | OVERRIDDEN | Admin triggers; >50% approved |
| Override: Admin Decision | PENDING/REJECTED | OVERRIDDEN | Admin provides new rankings |
| Freeze | APPROVED/OVERRIDDEN | FROZEN | Admin triggers (or auto on approval) |
| Revoke freeze | never | — | Cannot unfreeze. Must create new proposal |
Proposal Generation
Automatic Generation
When the LIVE_FINAL round completes (all projects scored), the system auto-generates a WinnerProposal:
async function generateWinnerProposal(
competitionId: string,
sourceRoundId: string,
category: CompetitionCategory,
proposedById: string // system or admin user ID
): Promise<WinnerProposal> {
// 1. Fetch all scored projects in this category from the live final round
const scores = await getAggregatedScores(sourceRoundId, category);
// 2. Rank by final score (jury weight + audience weight)
const ranked = scores
.sort((a, b) => b.finalScore - a.finalScore)
.map(s => s.projectId);
// 3. Build selection basis (evidence trail)
const selectionBasis = {
method: 'SCORE_RANKING',
sourceRound: sourceRoundId,
scoreBreakdown: scores.map(s => ({
projectId: s.projectId,
juryScore: s.juryScore,
audienceScore: s.audienceScore,
finalScore: s.finalScore,
rank: s.rank,
})),
generatedAt: new Date().toISOString(),
};
// 4. Create proposal
const proposal = await prisma.winnerProposal.create({
data: {
competitionId,
category,
status: 'PENDING',
rankedProjectIds: ranked,
selectionBasis,
sourceRoundId,
proposedById,
},
});
// 5. Create approval records for each jury member
const juryMembers = await getConfirmationJuryMembers(competitionId);
await prisma.winnerApproval.createMany({
data: juryMembers.map(member => ({
winnerProposalId: proposal.id,
userId: member.userId,
role: 'JURY_MEMBER',
})),
});
// 6. Send approval request notifications
await notifyJuryForConfirmation(proposal.id, juryMembers);
return proposal;
}
Manual Proposal Creation
Admins can also create proposals manually (e.g., after an admin-decision override):
async function createManualProposal(input: {
competitionId: string;
category: CompetitionCategory;
rankedProjectIds: string[];
justification: string;
adminId: string;
}): Promise<WinnerProposal> {
return prisma.winnerProposal.create({
data: {
competitionId: input.competitionId,
category: input.category,
status: 'PENDING',
rankedProjectIds: input.rankedProjectIds,
selectionBasis: {
method: 'ADMIN_MANUAL',
justification: input.justification,
createdBy: input.adminId,
createdAt: new Date().toISOString(),
},
sourceRoundId: await getLatestRoundId(input.competitionId),
proposedById: input.adminId,
},
});
}
Jury Approval Process
Approval Flow
When a WinnerProposal is created, each jury member on the confirmation jury receives a notification:
- Notification — Email + in-app alert: "Winner proposal for STARTUP category is ready for your review"
- Review — Jury member opens the confirmation page, sees:
- Ranked list of winners (1st, 2nd, 3rd...)
- Score breakdown per project
- Access to all submitted documents
- Comments from other jury members (if any have responded)
- Decision — Jury member clicks APPROVE or REJECT
- If APPROVE: records timestamp, optional comment
- If REJECT: must provide a reason (required text field)
- Progress tracking — Admin can see real-time progress: "3/5 jury members approved"
Jury Confirmation UI
┌──────────────────────────────────────────────────────────────────┐
│ 🏆 Winner Confirmation — STARTUP Category │
│ │
│ Proposal Status: PENDING (2/5 approved) │
│ Deadline: February 22, 2026 (5 days remaining) │
├──────────────────────────────────────────────────────────────────┤
│ │
│ Proposed Rankings: │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ 🥇 1st Place: OceanClean AI │ │
│ │ Final Score: 92.4 (Jury: 94.0 | Audience: 86.0) │ │
│ │ [View Submissions] [View Evaluations] │ │
│ ├──────────────────────────────────────────────────────────┤ │
│ │ 🥈 2nd Place: DeepReef Monitoring │ │
│ │ Final Score: 88.7 (Jury: 87.0 | Audience: 95.0) │ │
│ │ [View Submissions] [View Evaluations] │ │
│ ├──────────────────────────────────────────────────────────┤ │
│ │ 🥉 3rd Place: CoralGuard │ │
│ │ Final Score: 85.1 (Jury: 86.5 | Audience: 79.0) │ │
│ │ [View Submissions] [View Evaluations] │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ Score Methodology: Jury 70% / Audience 30% │
│ Source: Round 7 — Live Finals (completed Feb 15, 2026) │
│ │
│ ── Your Decision ────────────────────────────────────────── │
│ │
│ Comments (optional for approve, required for reject): │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ [ ✓ Approve Rankings ] [ ✗ Reject — Request Revision ] │
│ │
│ ── Other Jury Responses ─────────────────────────────────── │
│ ✓ Juror A — Approved (Feb 16) │
│ ✓ Juror B — Approved (Feb 16) "Great selections" │
│ ○ Juror C — Pending │
│ ○ Juror D — Pending │
│ ○ Juror E — Pending │
└──────────────────────────────────────────────────────────────────┘
Approval Logic
async function processApproval(
proposalId: string,
userId: string,
approved: boolean,
comments?: string
): Promise<{ proposal: WinnerProposal; isComplete: boolean }> {
// 1. Record the approval/rejection
await prisma.winnerApproval.update({
where: { winnerProposalId_userId: { winnerProposalId: proposalId, userId } },
data: {
approved,
comments,
respondedAt: new Date(),
},
});
// 2. Log to audit trail
await prisma.decisionAuditLog.create({
data: {
entityType: 'WINNER_PROPOSAL',
entityId: proposalId,
action: approved ? 'JURY_APPROVED' : 'JURY_REJECTED',
userId,
details: { comments },
},
});
// 3. Check if all required approvals are in
const config = await getConfirmationConfig(proposalId);
const allApprovals = await prisma.winnerApproval.findMany({
where: { winnerProposalId: proposalId },
});
const responded = allApprovals.filter(a => a.respondedAt !== null);
const approvedCount = responded.filter(a => a.approved === true).length;
const rejectedCount = responded.filter(a => a.approved === false).length;
const totalRequired = allApprovals.length;
let isComplete = false;
let newStatus: WinnerProposalStatus | null = null;
if (config.requireAllJuryApproval) {
// Unanimous mode
if (rejectedCount > 0) {
newStatus = 'REJECTED';
isComplete = true;
} else if (approvedCount === totalRequired) {
newStatus = 'APPROVED';
isComplete = true;
}
} else {
// Threshold mode
const threshold = config.minimumApprovalThreshold ?? 0.5;
if (responded.length === totalRequired) {
if (approvedCount / totalRequired >= threshold) {
newStatus = 'APPROVED';
} else {
newStatus = 'REJECTED';
}
isComplete = true;
}
}
let proposal: WinnerProposal;
if (newStatus) {
proposal = await prisma.winnerProposal.update({
where: { id: proposalId },
data: { status: newStatus },
});
// Auto-freeze if configured and approved
if (newStatus === 'APPROVED' && config.autoFreezeOnApproval) {
proposal = await freezeProposal(proposalId, 'SYSTEM');
}
// Notify admin of completion
await notifyAdminProposalComplete(proposalId, newStatus);
} else {
proposal = await prisma.winnerProposal.findUniqueOrThrow({
where: { id: proposalId },
});
// Notify admin of progress
if (config.notifyOnApproval && approved) {
await notifyAdminApprovalProgress(proposalId, approvedCount, totalRequired);
}
if (config.notifyOnRejection && !approved) {
await notifyAdminRejection(proposalId, userId, comments);
}
}
return { proposal, isComplete };
}
Admin Override System
The admin override exists for when jury consensus fails or when extraordinary circumstances require admin intervention.
Override Modes
FORCE_MAJORITY
Accept the proposal if more than 50% of jury members approved, even though unanimous approval was required.
async function overrideForceMajority(
proposalId: string,
adminId: string,
reason: string
): Promise<WinnerProposal> {
const approvals = await prisma.winnerApproval.findMany({
where: { winnerProposalId: proposalId },
});
const approvedCount = approvals.filter(a => a.approved === true).length;
const total = approvals.length;
if (approvedCount <= total / 2) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Cannot force majority: only ${approvedCount}/${total} approved (need >50%)`,
});
}
const proposal = await prisma.winnerProposal.update({
where: { id: proposalId },
data: {
status: 'OVERRIDDEN',
overrideById: adminId,
overrideReason: reason,
overrideAt: new Date(),
},
});
await prisma.decisionAuditLog.create({
data: {
entityType: 'WINNER_PROPOSAL',
entityId: proposalId,
action: 'ADMIN_FORCE_MAJORITY',
userId: adminId,
details: {
reason,
approvedCount,
totalJurors: total,
rejections: approvals
.filter(a => a.approved === false)
.map(a => ({ userId: a.userId, comments: a.comments })),
},
},
});
return proposal;
}
ADMIN_DECISION
Admin directly selects winners, bypassing jury vote entirely. This is the nuclear option for deadlocked juries or exceptional circumstances.
async function overrideAdminDecision(
proposalId: string,
adminId: string,
newRankedProjectIds: string[],
reason: string
): Promise<WinnerProposal> {
// Store original rankings for audit
const original = await prisma.winnerProposal.findUniqueOrThrow({
where: { id: proposalId },
});
const proposal = await prisma.winnerProposal.update({
where: { id: proposalId },
data: {
status: 'OVERRIDDEN',
rankedProjectIds: newRankedProjectIds,
overrideById: adminId,
overrideReason: reason,
overrideAt: new Date(),
selectionBasis: {
...((original.selectionBasis as Record<string, unknown>) ?? {}),
overrideHistory: {
originalRanking: original.rankedProjectIds,
newRanking: newRankedProjectIds,
reason,
overriddenBy: adminId,
overriddenAt: new Date().toISOString(),
},
},
},
});
await prisma.decisionAuditLog.create({
data: {
entityType: 'WINNER_PROPOSAL',
entityId: proposalId,
action: 'ADMIN_DECISION_OVERRIDE',
userId: adminId,
details: {
reason,
originalRanking: original.rankedProjectIds,
newRanking: newRankedProjectIds,
},
},
});
return proposal;
}
Admin Override UI
┌──────────────────────────────────────────────────────────────────┐
│ ⚠️ Override Winner Proposal — STARTUP Category │
│ │
│ Current Status: REJECTED │
│ Jury Votes: 3 Approved / 2 Rejected │
│ │
│ ── Override Options ─────────────────────────────────────── │
│ │
│ ○ Force Majority │
│ Accept current rankings since 3/5 (60%) approved. │
│ Rankings remain as originally proposed. │
│ │
│ ○ Admin Decision │
│ Override rankings entirely. You will set the final order. │
│ │
│ ── Override Reason (required) ───────────────────────────── │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Two jurors had scheduling conflicts and could not review │ │
│ │ all presentations. Accepting majority recommendation. │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ ⚠ This action is logged and cannot be undone. │
│ │
│ [ Cancel ] [ Apply Override ] │
└──────────────────────────────────────────────────────────────────┘
Admin Decision — Reorder UI
When ADMIN_DECISION mode is selected:
┌──────────────────────────────────────────────────────────────────┐
│ Admin Decision — Set Final Rankings │
│ │
│ Drag to reorder. This becomes the official result. │
│ │
│ ┌────┬──────────────────────────────┬──────────┬────────────┐ │
│ │ # │ Project │ Score │ Actions │ │
│ ├────┼──────────────────────────────┼──────────┼────────────┤ │
│ │ 1 │ ≡ OceanClean AI │ 92.4 │ ↑ ↓ │ │
│ │ 2 │ ≡ DeepReef Monitoring │ 88.7 │ ↑ ↓ │ │
│ │ 3 │ ≡ CoralGuard │ 85.1 │ ↑ ↓ │ │
│ │ 4 │ ≡ WaveEnergy Solutions │ 82.3 │ ↑ ↓ │ │
│ │ 5 │ ≡ PlasticHarvest │ 79.8 │ ↑ ↓ │ │
│ └────┴──────────────────────────────┴──────────┴────────────┘ │
│ │
│ Why are you overriding the score-based ranking? │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ [ Cancel ] [ Confirm Final Rankings ] │
└──────────────────────────────────────────────────────────────────┘
Result Freezing
What Freeze Does
Freezing a WinnerProposal makes it immutable — the official, permanent record of the competition results.
async function freezeProposal(
proposalId: string,
triggeredBy: string // userId or 'SYSTEM' for auto-freeze
): Promise<WinnerProposal> {
const proposal = await prisma.winnerProposal.findUniqueOrThrow({
where: { id: proposalId },
});
if (proposal.status !== 'APPROVED' && proposal.status !== 'OVERRIDDEN') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Cannot freeze proposal in ${proposal.status} status. Must be APPROVED or OVERRIDDEN.`,
});
}
const frozen = await prisma.winnerProposal.update({
where: { id: proposalId },
data: {
status: 'FROZEN',
frozenById: triggeredBy === 'SYSTEM' ? null : triggeredBy,
frozenAt: new Date(),
},
});
// Log the freeze
await prisma.decisionAuditLog.create({
data: {
entityType: 'WINNER_PROPOSAL',
entityId: proposalId,
action: 'RESULTS_FROZEN',
userId: triggeredBy === 'SYSTEM' ? undefined : triggeredBy,
details: {
category: proposal.category,
rankedProjectIds: proposal.rankedProjectIds,
frozenAt: new Date().toISOString(),
method: triggeredBy === 'SYSTEM' ? 'AUTO_FREEZE' : 'MANUAL_FREEZE',
},
},
});
// Update round status to COMPLETED
const config = await getConfirmationConfig(proposalId);
if (config.perCategory) {
// Check if ALL categories are frozen before completing the round
const allProposals = await prisma.winnerProposal.findMany({
where: { competitionId: proposal.competitionId },
});
const allFrozen = allProposals.every(p => p.status === 'FROZEN');
if (allFrozen) {
await completeConfirmationRound(proposal.competitionId);
}
} else {
await completeConfirmationRound(proposal.competitionId);
}
// Send freeze notifications
await notifyResultsFrozen(proposalId);
return frozen;
}
Freeze Immutability
Once frozen, a WinnerProposal cannot be modified. The following are enforced:
| Operation | Allowed on FROZEN? | Alternative |
|---|---|---|
| Change rankings | No | Create a new WinnerProposal |
| Change status | No | — |
| Add approvals | No | — |
| Delete proposal | No | — |
| Create new proposal | Yes | New proposal supersedes (admin must mark old as "superseded" in notes) |
Freeze Guard Middleware
// Applied to all mutation procedures on WinnerProposal
function assertNotFrozen(proposalId: string) {
const proposal = await prisma.winnerProposal.findUniqueOrThrow({
where: { id: proposalId },
});
if (proposal.status === 'FROZEN') {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'This winner proposal is frozen and cannot be modified. Create a new proposal if changes are needed.',
});
}
}
Special Awards in Confirmation
When includeSpecialAwards: true, the confirmation round also covers special award winners.
Special Award Winner Proposals
Each special award generates its own mini-proposal, but the confirmation process bundles them together:
type ConfirmationPackage = {
// Main competition winners
mainProposals: {
startup: WinnerProposal;
concept: WinnerProposal;
};
// Special award winners
awardProposals: {
awardId: string;
awardName: string;
winnerId: string; // project ID
method: 'JURY_VOTE' | 'AUDIENCE_VOTE' | 'COMBINED_VOTE';
score: number;
}[];
};
Confirmation UI with Awards
┌──────────────────────────────────────────────────────────────────┐
│ Winner Confirmation — Full Package │
│ │
│ ═══ STARTUP Category ════════════════════════════════════════ │
│ Status: APPROVED ✓ [Freeze Results] │
│ 1st: OceanClean AI (92.4) │
│ 2nd: DeepReef Monitoring (88.7) │
│ 3rd: CoralGuard (85.1) │
│ │
│ ═══ CONCEPT Category ════════════════════════════════════════ │
│ Status: PENDING (4/5 approved) │
│ 1st: BlueTide Analytics (89.2) │
│ 2nd: MarineData Hub (84.6) │
│ 3rd: SeaWatch Network (81.3) │
│ │
│ ═══ Special Awards ══════════════════════════════════════════ │
│ Innovation Award: OceanClean AI — Jury Vote (Score: 9.2) │
│ Impact Award: BlueTide Analytics — Combined (Score: 8.8) │
│ Community Award: SeaWatch Network — Audience Vote │
│ │
│ ── Actions ───────────────────────────────────────────────── │
│ [ Freeze All Approved ] [ Override Pending ] [ Export PDF ] │
└──────────────────────────────────────────────────────────────────┘
Admin Confirmation Dashboard
Full Dashboard Layout
┌──────────────────────────────────────────────────────────────────┐
│ MOPC 2026 — Winner Confirmation │
│ Round 8 of 8 | Status: ACTIVE │
├──────────────────────────────────────────────────────────────────┤
│ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ Progress Summary │ │
│ │ ┌──────────┬──────────┬──────────┬───────────────────┐ │ │
│ │ │ Category │ Status │ Approvals│ Deadline │ │ │
│ │ ├──────────┼──────────┼──────────┼───────────────────┤ │ │
│ │ │ STARTUP │ APPROVED │ 5/5 ✓ │ — (complete) │ │ │
│ │ │ CONCEPT │ PENDING │ 4/5 │ Feb 22 (5 days) │ │ │
│ │ └──────────┴──────────┴──────────┴───────────────────┘ │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ Jury Approval Timeline │ │
│ │ │ │
│ │ Feb 15 ●────────────────────────────── Proposal sent │ │
│ │ Feb 16 ●── Juror A approved ✓ │ │
│ │ Feb 16 ●── Juror B approved ✓ │ │
│ │ Feb 17 ●── Juror C approved ✓ │ │
│ │ Feb 17 ●── Juror D approved ✓ │ │
│ │ Feb ?? ○── Juror E — awaiting response │ │
│ │ │ │
│ │ [Send Reminder to Juror E] │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ Audit Trail │ │
│ │ Feb 17 14:32 — Juror D approved CONCEPT proposal │ │
│ │ Feb 17 14:30 — Juror D approved STARTUP proposal │ │
│ │ Feb 17 10:15 — Juror C approved CONCEPT proposal │ │
│ │ Feb 16 09:22 — Juror B approved with comment │ │
│ │ Feb 16 09:10 — Juror A approved both categories │ │
│ │ Feb 15 18:00 — Proposals auto-generated from Round 7 │ │
│ │ [View Full Audit Log] │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │
│ Actions: │
│ [ Freeze STARTUP Results ] [ Override CONCEPT ] │
│ [ Send Reminder ] [ Export Results PDF ] [ Create New Proposal]│
└──────────────────────────────────────────────────────────────────┘
Notification Flow
Notification Events
| Event | Recipients | Channel | Timing |
|---|---|---|---|
| Proposal created | All confirmation jury | Email + In-app | Immediate |
| Jury member approves | Admin | In-app | Immediate |
| Jury member rejects | Admin | Email + In-app | Immediate |
| All approved (unanimous) | Admin + All jury | Immediate | |
| Proposal rejected (failed) | Admin | Email + In-app | Immediate |
| Admin override applied | All jury | Immediate | |
| Results frozen | All jury + Admin | Email + In-app | Immediate |
| Approval deadline approaching | Pending jury members | Per reminderSchedule | |
| Approval deadline passed | Admin | Email + In-app | Immediate |
Notification Templates
const CONFIRMATION_NOTIFICATIONS = {
PROPOSAL_CREATED: {
subject: 'MOPC 2026 — Winner Confirmation Required',
body: `The winner proposal for {{category}} category is ready for your review.
Please log in and confirm or reject the proposed rankings by {{deadline}}.
Proposed Winners:
{{#each rankedProjects}}
{{rank}}. {{name}} (Score: {{score}})
{{/each}}`,
},
APPROVAL_RECEIVED: {
subject: 'MOPC 2026 — Confirmation Progress Update',
body: `{{jurorName}} has {{approved ? "approved" : "rejected"}} the {{category}} proposal.
Progress: {{approvedCount}}/{{totalJurors}} approved.
{{#if comments}}Comment: "{{comments}}"{{/if}}`,
},
RESULTS_FROZEN: {
subject: 'MOPC 2026 — Official Results Confirmed',
body: `The {{category}} category results have been officially confirmed and frozen.
Official Winners:
{{#each rankedProjects}}
{{rank}}. {{name}}
{{/each}}
These results are now immutable and represent the official outcome.`,
},
DEADLINE_REMINDER: {
subject: 'MOPC 2026 — Confirmation Deadline in {{daysRemaining}} days',
body: `You have not yet responded to the {{category}} winner proposal.
Please review and respond by {{deadline}}.
[Review Proposal →]`,
},
};
API Changes
New tRPC Procedures
// src/server/routers/winner-confirmation.ts
export const winnerConfirmationRouter = router({
// ── Proposals ──────────────────────────────────────────
/** Generate proposals from live final scores */
generateProposals: adminProcedure
.input(z.object({
competitionId: z.string(),
sourceRoundId: z.string(),
}))
.mutation(async ({ input, ctx }) => { ... }),
/** Create manual proposal (admin override scenario) */
createManualProposal: adminProcedure
.input(z.object({
competitionId: z.string(),
category: z.enum(['STARTUP', 'BUSINESS_CONCEPT']),
rankedProjectIds: z.array(z.string()).min(1),
justification: z.string().min(10),
}))
.mutation(async ({ input, ctx }) => { ... }),
/** Get proposal with approvals */
getProposal: protectedProcedure
.input(z.object({ proposalId: z.string() }))
.query(async ({ input }) => { ... }),
/** List all proposals for a competition */
listProposals: protectedProcedure
.input(z.object({ competitionId: z.string() }))
.query(async ({ input }) => { ... }),
/** Get the full confirmation package (main + awards) */
getConfirmationPackage: adminProcedure
.input(z.object({ competitionId: z.string() }))
.query(async ({ input }) => { ... }),
// ── Approvals ──────────────────────────────────────────
/** Jury member approves or rejects */
submitApproval: juryProcedure
.input(z.object({
proposalId: z.string(),
approved: z.boolean(),
comments: z.string().optional(),
}))
.mutation(async ({ input, ctx }) => { ... }),
/** Get my pending approvals */
getMyPendingApprovals: juryProcedure
.query(async ({ ctx }) => { ... }),
/** Get approval progress for a proposal */
getApprovalProgress: protectedProcedure
.input(z.object({ proposalId: z.string() }))
.query(async ({ input }) => { ... }),
// ── Admin Overrides ────────────────────────────────────
/** Force majority acceptance */
overrideForceMajority: adminProcedure
.input(z.object({
proposalId: z.string(),
reason: z.string().min(10),
}))
.mutation(async ({ input, ctx }) => { ... }),
/** Admin directly sets winners */
overrideAdminDecision: adminProcedure
.input(z.object({
proposalId: z.string(),
newRankedProjectIds: z.array(z.string()).min(1),
reason: z.string().min(10),
}))
.mutation(async ({ input, ctx }) => { ... }),
// ── Freeze ─────────────────────────────────────────────
/** Freeze a proposal (make results official) */
freezeProposal: adminProcedure
.input(z.object({ proposalId: z.string() }))
.mutation(async ({ input, ctx }) => { ... }),
/** Freeze all approved proposals at once */
freezeAll: adminProcedure
.input(z.object({ competitionId: z.string() }))
.mutation(async ({ input, ctx }) => { ... }),
// ── Notifications ──────────────────────────────────────
/** Send reminder to pending jury members */
sendReminder: adminProcedure
.input(z.object({
proposalId: z.string(),
userIds: z.array(z.string()).optional(), // specific jurors, or all pending
}))
.mutation(async ({ input }) => { ... }),
// ── Export ─────────────────────────────────────────────
/** Export results as PDF */
exportResultsPdf: adminProcedure
.input(z.object({ competitionId: z.string() }))
.mutation(async ({ input }) => { ... }),
/** Export results as structured JSON */
exportResultsJson: adminProcedure
.input(z.object({ competitionId: z.string() }))
.query(async ({ input }) => { ... }),
});
Service Functions
winner-confirmation.ts
// src/server/services/winner-confirmation.ts
/** Generate proposals for all categories from live final scores */
export async function generateProposalsFromScores(
competitionId: string,
sourceRoundId: string,
triggeredBy: string
): Promise<WinnerProposal[]>;
/** Create a manual proposal with admin-specified rankings */
export async function createManualProposal(
competitionId: string,
category: CompetitionCategory,
rankedProjectIds: string[],
justification: string,
adminId: string
): Promise<WinnerProposal>;
/** Process a jury member's approval/rejection */
export async function processApproval(
proposalId: string,
userId: string,
approved: boolean,
comments?: string
): Promise<{ proposal: WinnerProposal; isComplete: boolean }>;
/** Override with force-majority rule */
export async function overrideForceMajority(
proposalId: string,
adminId: string,
reason: string
): Promise<WinnerProposal>;
/** Override with admin-selected rankings */
export async function overrideAdminDecision(
proposalId: string,
adminId: string,
newRankedProjectIds: string[],
reason: string
): Promise<WinnerProposal>;
/** Freeze a proposal — makes results immutable */
export async function freezeProposal(
proposalId: string,
triggeredBy: string
): Promise<WinnerProposal>;
/** Freeze all approved/overridden proposals for a competition */
export async function freezeAllApproved(
competitionId: string,
adminId: string
): Promise<WinnerProposal[]>;
/** Get the full confirmation package (main winners + special awards) */
export async function getConfirmationPackage(
competitionId: string
): Promise<ConfirmationPackage>;
/** Check if all categories are frozen for a competition */
export async function isFullyFrozen(
competitionId: string
): Promise<boolean>;
/** Send reminder notifications to pending jury members */
export async function sendApprovalReminder(
proposalId: string,
specificUserIds?: string[]
): Promise<void>;
/** Get aggregated scores from the source round */
export async function getAggregatedScores(
roundId: string,
category: CompetitionCategory
): Promise<AggregatedScore[]>;
/** Export frozen results as a structured document */
export async function exportResults(
competitionId: string,
format: 'json' | 'pdf'
): Promise<Buffer | Record<string, unknown>>;
Edge Cases
| Scenario | Handling |
|---|---|
| Jury member doesn't respond by deadline | Admin notified. Can send reminders or use override |
| All jurors reject | Status = REJECTED. Admin must override or create new proposal |
| Juror tries to change vote after submitting | Not allowed. Must contact admin to reset vote |
| Admin resets a juror's vote | Audit logged. Juror re-notified to vote again |
| Tie in live finals scores | Proposal lists tied projects at same rank. Admin resolves via override |
| Proposal frozen, then error discovered | Cannot unfreeze. Admin creates new proposal with explanation |
| Juror is on multiple categories | Separate approval records per proposal (per-category) |
| Competition has no special awards | includeSpecialAwards is false; only main proposals |
| Admin freezes before all categories confirmed | Allowed — each category freezes independently |
| Network failure during approval | Optimistic UI with retry. Server-side idempotency via unique constraint |
| Juror removed from jury group after proposal sent | Approval record remains but marked as N/A. Threshold recalculated |
| Multiple proposals for same category | Only latest non-frozen proposal is active. Previous ones archived |
| Live finals not yet complete | Cannot generate proposal — round must be in COMPLETED status |
| Admin tries to freeze PENDING proposal | Blocked — must be APPROVED or OVERRIDDEN first |
Integration Points
Inbound (from other rounds/systems)
| Source | Data | Purpose |
|---|---|---|
| Round 7 (LIVE_FINAL) | Aggregated scores, rankings | Auto-generate WinnerProposal |
| JuryGroup (Jury 3) | Member list | Determine who must approve |
| SpecialAward system | Award winners | Include in confirmation package |
| Competition settings | Category config | Per-category proposals |
Outbound (to other systems)
| Target | Data | Purpose |
|---|---|---|
| DecisionAuditLog | All actions | Full audit trail |
| Notification system | Events | Email + in-app alerts |
| Export service | Frozen results | PDF/JSON export for records |
| Admin dashboard | Progress metrics | Real-time status display |
| Competition status | COMPLETED flag | Mark competition as finished |
Confirmation → Competition Completion
When all proposals are frozen:
async function completeConfirmationRound(competitionId: string): Promise<void> {
// 1. Mark the confirmation round as COMPLETED
const confirmationRound = await prisma.round.findFirst({
where: { competitionId, roundType: 'CONFIRMATION' },
});
if (confirmationRound) {
await prisma.round.update({
where: { id: confirmationRound.id },
data: { status: 'ROUND_COMPLETED' },
});
}
// 2. Update competition status
await prisma.competition.update({
where: { id: competitionId },
data: { status: 'CLOSED' },
});
// 3. Log competition completion
await prisma.decisionAuditLog.create({
data: {
entityType: 'COMPETITION',
entityId: competitionId,
action: 'COMPETITION_COMPLETED',
details: {
completedAt: new Date().toISOString(),
reason: 'All winner proposals frozen',
},
},
});
// 4. Send competition-complete notifications
await notifyCompetitionComplete(competitionId);
}
Security Considerations
| Concern | Mitigation |
|---|---|
| Jury member votes for wrong proposal | Proposals are category-specific; UI shows only relevant proposal |
| Admin forges jury approvals | Approvals tied to authenticated session; audit log captures userId |
| Results tampered after freeze | FROZEN status enforced at database level; no UPDATE allowed |
| Unauthorized freeze | Only SUPER_ADMIN and PROGRAM_ADMIN can freeze (adminProcedure) |
| Replay attack on approval | Unique constraint on [winnerProposalId, userId] prevents double-voting |
| Override without justification | reason field is required (min 10 chars) on all overrides |
| PDF export tampering | Export includes cryptographic hash of frozen proposal data |
Results Export Format
JSON Export
type ExportedResults = {
competition: {
id: string;
name: string;
programYear: number;
};
exportedAt: string;
exportedBy: string;
categories: {
category: string;
proposalId: string;
status: WinnerProposalStatus;
frozenAt: string;
winners: {
rank: number;
project: {
id: string;
name: string;
teamName: string;
category: string;
};
scores: {
juryScore: number;
audienceScore: number;
finalScore: number;
};
}[];
approvals: {
jurorName: string;
approved: boolean;
respondedAt: string;
comments?: string;
}[];
override?: {
type: string;
reason: string;
overriddenBy: string;
overriddenAt: string;
};
}[];
specialAwards: {
awardName: string;
winnerProject: string;
method: string;
score: number;
}[];
// Integrity hash
integrityHash: string;
};
PDF Export Structure
The PDF export contains:
- Cover page — Competition name, year, date of confirmation
- Main winners — Per category: ranked list with scores, team info
- Special awards — Award name, winner, scoring method
- Jury confirmations — List of all approvals with timestamps
- Override record — If any override was used, full details
- Audit summary — Key events from the audit trail
- Integrity hash — SHA-256 of all frozen proposal data