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

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:

  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

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 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

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:

  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