MOPC-App/docs/claude-architecture-redesign/10-round-confirmation.md

1300 lines
53 KiB
Markdown

# 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<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):
```typescript
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:
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<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.
```typescript
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.
```typescript
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
```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<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:
```typescript
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
```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