# 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 ```typescript 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 ```typescript 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: ```typescript async function generateWinnerProposal( competitionId: string, sourceRoundId: string, category: CompetitionCategory, proposedById: string // system or admin user ID ): Promise { // 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): ```typescript async function createManualProposal(input: { competitionId: string; category: CompetitionCategory; rankedProjectIds: string[]; justification: string; adminId: string; }): Promise { 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: 1. **Notification** — Email + in-app alert: "Winner proposal for STARTUP category is ready for your review" 2. **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) 3. **Decision** — Jury member clicks APPROVE or REJECT - If APPROVE: records timestamp, optional comment - If REJECT: must provide a reason (required text field) 4. **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 ```typescript 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. ```typescript async function overrideForceMajority( proposalId: string, adminId: string, reason: string ): Promise { 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. ```typescript async function overrideAdminDecision( proposalId: string, adminId: string, newRankedProjectIds: string[], reason: string ): Promise { // 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) ?? {}), 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. ```typescript async function freezeProposal( proposalId: string, triggeredBy: string // userId or 'SYSTEM' for auto-freeze ): Promise { 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 ```typescript // 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: ```typescript 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 | Email | Immediate | | Proposal rejected (failed) | Admin | Email + In-app | Immediate | | Admin override applied | All jury | Email | Immediate | | Results frozen | All jury + Admin | Email + In-app | Immediate | | Approval deadline approaching | Pending jury members | Email | Per reminderSchedule | | Approval deadline passed | Admin | Email + In-app | Immediate | ### Notification Templates ```typescript 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 ```typescript // 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 ```typescript // 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; /** Create a manual proposal with admin-specified rankings */ export async function createManualProposal( competitionId: string, category: CompetitionCategory, rankedProjectIds: string[], justification: string, adminId: string ): Promise; /** 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; /** Override with admin-selected rankings */ export async function overrideAdminDecision( proposalId: string, adminId: string, newRankedProjectIds: string[], reason: string ): Promise; /** Freeze a proposal — makes results immutable */ export async function freezeProposal( proposalId: string, triggeredBy: string ): Promise; /** Freeze all approved/overridden proposals for a competition */ export async function freezeAllApproved( competitionId: string, adminId: string ): Promise; /** Get the full confirmation package (main winners + special awards) */ export async function getConfirmationPackage( competitionId: string ): Promise; /** Check if all categories are frozen for a competition */ export async function isFullyFrozen( competitionId: string ): Promise; /** Send reminder notifications to pending jury members */ export async function sendApprovalReminder( proposalId: string, specificUserIds?: string[] ): Promise; /** Get aggregated scores from the source round */ export async function getAggregatedScores( roundId: string, category: CompetitionCategory ): Promise; /** Export frozen results as a structured document */ export async function exportResults( competitionId: string, format: 'json' | 'pdf' ): Promise>; ``` --- ## 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: ```typescript async function completeConfirmationRound(competitionId: string): Promise { // 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 ```typescript 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: 1. **Cover page** — Competition name, year, date of confirmation 2. **Main winners** — Per category: ranked list with scores, team info 3. **Special awards** — Award name, winner, scoring method 4. **Jury confirmations** — List of all approvals with timestamps 5. **Override record** — If any override was used, full details 6. **Audit summary** — Key events from the audit trail 7. **Integrity hash** — SHA-256 of all frozen proposal data