# Jury Groups — Multi-Jury Architecture

## Overview

The **JuryGroup** model is the backbone of the redesigned jury system. Instead of implicit jury membership derived from per-stage assignments, juries are now **first-class named entities** — "Jury 1", "Jury 2", "Jury 3", "Innovation Award Jury" — with explicit membership, configurable assignment caps, and per-juror overrides.

### Why This Matters

| Before (Current) | After (Redesigned) |
|---|---|
| Juries are implicit — "Jury 1" exists only in admin's head | JuryGroup is a named model with `id`, `name`, `description` |
| Assignment caps are per-stage config | Caps are per-juror on JuryGroupMember (with group defaults) |
| No concept of "which jury is this juror on" | JuryGroupMember links User to JuryGroup explicitly |
| Same juror can't be on multiple juries (no grouping) | A User can belong to multiple JuryGroups |
| Category quotas don't exist | Per-juror STARTUP/CONCEPT ratio preferences |
| No juror onboarding preferences | JuryGroupMember stores language, expertise, preferences |

### Jury Groups in the 8-Step Flow

```
Round 1: INTAKE          — no jury
Round 2: FILTERING       — no jury (AI-powered)
Round 3: EVALUATION      — ► Jury 1 (semi-finalist selection)
Round 4: SUBMISSION      — no jury
Round 5: EVALUATION      — ► Jury 2 (finalist selection)
Round 6: MENTORING       — no jury
Round 7: LIVE_FINAL      — ► Jury 3 (live finals scoring)
Round 8: CONFIRMATION    — ► Jury 3 (winner confirmation)

Special Awards:
  Innovation Award       — ► Innovation Jury (may overlap with Jury 2)
  Impact Award           — ► Impact Jury (dedicated members)
```

---

## Data Model

### JuryGroup

```prisma
model JuryGroup {
  id              String @id @default(cuid())
  competitionId   String
  name            String      // "Jury 1", "Jury 2", "Jury 3", "Innovation Award Jury"
  description     String?     // "Semi-finalist evaluation jury — reviews 60+ applications"
  isActive        Boolean @default(true)

  // Default assignment configuration for this jury
  defaultMaxAssignments    Int     @default(20)
  defaultCapMode           CapMode @default(SOFT)
  softCapBuffer            Int     @default(2)    // Extra assignments above cap

  // Default category quotas (per juror)
  defaultCategoryQuotas    Json?   @db.JsonB
  // Shape: { "STARTUP": { min: 5, max: 12 }, "BUSINESS_CONCEPT": { min: 5, max: 12 } }

  // Onboarding
  onboardingFormId         String? // Link to onboarding form (expertise, preferences)

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  // Relations
  competition Competition       @relation(...)
  members     JuryGroupMember[]
  rounds      Round[]           // Rounds this jury is assigned to
  assignments Assignment[]      // Assignments made through this jury group
}
```

### JuryGroupMember

```prisma
model JuryGroupMember {
  id              String @id @default(cuid())
  juryGroupId     String
  userId          String
  role            String @default("MEMBER")  // "MEMBER" | "CHAIR" | "OBSERVER"

  // Per-juror overrides (null = use group defaults)
  maxAssignmentsOverride   Int?
  capModeOverride          CapMode?
  categoryQuotasOverride   Json?   @db.JsonB

  // Juror preferences (set during onboarding)
  preferredStartupRatio    Float?   // 0.0–1.0 (e.g., 0.6 = 60% startups)
  expertiseTags            String[] // ["ocean-tech", "marine-biology", "finance"]
  languagePreferences      String[] // ["en", "fr"]
  notes                    String?  // Admin notes about this juror

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  // Relations
  juryGroup JuryGroup @relation(...)
  user      User      @relation(...)

  @@unique([juryGroupId, userId])
  @@index([juryGroupId])
  @@index([userId])
}
```

### CapMode Enum

```prisma
enum CapMode {
  HARD    // Absolute maximum — algorithm cannot exceed under any circumstance
  SOFT    // Target maximum — can exceed by softCapBuffer for load balancing
  NONE    // No cap — unlimited assignments (use with caution)
}
```

### Cap Behavior

| Mode | Max | Buffer | Effective Limit | Behavior |
|------|-----|--------|-----------------|----------|
| HARD | 20 | — | 20 | Algorithm stops at exactly 20. No exceptions. |
| SOFT | 20 | 2 | 22 | Algorithm targets 20 but can go to 22 if needed for balanced distribution |
| NONE | — | — | ∞ | No limit. Juror can be assigned any number of projects |

```typescript
function getEffectiveCap(member: JuryGroupMember, group: JuryGroup): number | null {
  const capMode = member.capModeOverride ?? group.defaultCapMode;
  const maxAssignments = member.maxAssignmentsOverride ?? group.defaultMaxAssignments;

  switch (capMode) {
    case 'HARD':
      return maxAssignments;
    case 'SOFT':
      return maxAssignments + group.softCapBuffer;
    case 'NONE':
      return null; // no limit
  }
}

function canAssignMore(
  member: JuryGroupMember,
  group: JuryGroup,
  currentCount: number
): { allowed: boolean; reason?: string } {
  const cap = getEffectiveCap(member, group);

  if (cap === null) return { allowed: true };

  if (currentCount >= cap) {
    return {
      allowed: false,
      reason: `Juror has reached ${capMode === 'HARD' ? 'hard' : 'soft'} cap of ${cap} assignments`,
    };
  }

  return { allowed: true };
}
```

---

## Category Quotas

### How Quotas Work

Each jury group (and optionally each member) can define minimum and maximum assignments per competition category. This ensures balanced coverage:

```typescript
type CategoryQuotas = {
  STARTUP: { min: number; max: number };
  BUSINESS_CONCEPT: { min: number; max: number };
};

// Example: group default
const defaultQuotas: CategoryQuotas = {
  STARTUP: { min: 5, max: 12 },
  BUSINESS_CONCEPT: { min: 5, max: 12 },
};
```

### Quota Resolution

Per-juror overrides take precedence over group defaults:

```typescript
function getEffectiveQuotas(
  member: JuryGroupMember,
  group: JuryGroup
): CategoryQuotas | null {
  if (member.categoryQuotasOverride) {
    return member.categoryQuotasOverride as CategoryQuotas;
  }
  if (group.defaultCategoryQuotas) {
    return group.defaultCategoryQuotas as CategoryQuotas;
  }
  return null; // no quotas — assign freely
}
```

### Quota Enforcement During Assignment

```typescript
function checkCategoryQuota(
  member: JuryGroupMember,
  group: JuryGroup,
  category: CompetitionCategory,
  currentCategoryCount: number
): { allowed: boolean; warning?: string } {
  const quotas = getEffectiveQuotas(member, group);
  if (!quotas) return { allowed: true };

  const categoryQuota = quotas[category];
  if (!categoryQuota) return { allowed: true };

  if (currentCategoryCount >= categoryQuota.max) {
    return {
      allowed: false,
      warning: `Juror has reached max ${categoryQuota.max} for ${category}`,
    };
  }

  return { allowed: true };
}

function checkMinimumQuotasMet(
  member: JuryGroupMember,
  group: JuryGroup,
  categoryCounts: Record<CompetitionCategory, number>
): { met: boolean; deficits: string[] } {
  const quotas = getEffectiveQuotas(member, group);
  if (!quotas) return { met: true, deficits: [] };

  const deficits: string[] = [];
  for (const [category, quota] of Object.entries(quotas)) {
    const count = categoryCounts[category as CompetitionCategory] ?? 0;
    if (count < quota.min) {
      deficits.push(`${category}: ${count}/${quota.min} minimum`);
    }
  }

  return { met: deficits.length === 0, deficits };
}
```

---

## Preferred Startup Ratio

Each juror can express a preference for what percentage of their assignments should be Startups vs Concepts.

```typescript
// On JuryGroupMember:
preferredStartupRatio: Float?  // 0.0 to 1.0

// Usage in assignment algorithm:
function calculateRatioAlignmentScore(
  member: JuryGroupMember,
  candidateCategory: CompetitionCategory,
  currentStartupCount: number,
  currentConceptCount: number
): number {
  const preference = member.preferredStartupRatio;
  if (preference === null || preference === undefined) return 0; // no preference

  const totalAfterAssignment = currentStartupCount + currentConceptCount + 1;
  const startupCountAfter = candidateCategory === 'STARTUP'
    ? currentStartupCount + 1
    : currentStartupCount;
  const ratioAfter = startupCountAfter / totalAfterAssignment;

  // Score: how close does adding this assignment bring the ratio to preference?
  const deviation = Math.abs(ratioAfter - preference);
  // Scale: 0 deviation = 10pts, 0.5 deviation = 0pts
  return Math.max(0, 10 * (1 - deviation * 2));
}
```

This score feeds into the assignment algorithm alongside tag overlap, workload balance, and geo-diversity.

---

## Juror Roles

Each JuryGroupMember has a `role` field:

| Role | Capabilities | Description |
|------|-------------|-------------|
| `MEMBER` | Evaluate assigned projects, vote in live finals, confirm winners | Standard jury member |
| `CHAIR` | All MEMBER capabilities + view all evaluations, moderate discussions, suggest assignments | Jury chairperson — has broader visibility |
| `OBSERVER` | View evaluations (read-only), no scoring or voting | Observes the jury process without participating |

### Role-Based Visibility

```typescript
function getJurorVisibility(
  role: string,
  ownAssignments: Assignment[]
): VisibilityScope {
  switch (role) {
    case 'CHAIR':
      return {
        canSeeAllEvaluations: true,
        canSeeAllAssignments: true,
        canModerateDiscussions: true,
        canSuggestReassignments: true,
      };
    case 'MEMBER':
      return {
        canSeeAllEvaluations: false, // only their own
        canSeeAllAssignments: false,
        canModerateDiscussions: false,
        canSuggestReassignments: false,
      };
    case 'OBSERVER':
      return {
        canSeeAllEvaluations: true,  // read-only
        canSeeAllAssignments: true,
        canModerateDiscussions: false,
        canSuggestReassignments: false,
      };
  }
}
```

---

## Multi-Jury Membership

A single user can be on multiple jury groups. This is common for:
- A juror on Jury 2 (finalist selection) AND Innovation Award Jury
- A senior juror on Jury 1 AND Jury 3 (semi-finalist + live finals)

### Overlap Handling

```typescript
// Get all jury groups for a user in a competition
async function getUserJuryGroups(
  userId: string,
  competitionId: string
): Promise<JuryGroupMember[]> {
  return prisma.juryGroupMember.findMany({
    where: {
      userId,
      juryGroup: { competitionId },
    },
    include: { juryGroup: true },
  });
}

// Check if user is on a specific jury
async function isUserOnJury(
  userId: string,
  juryGroupId: string
): Promise<boolean> {
  const member = await prisma.juryGroupMember.findUnique({
    where: { juryGroupId_userId: { juryGroupId, userId } },
  });
  return member !== null;
}
```

### Cross-Jury COI Propagation

When a juror declares a Conflict of Interest for a project in one jury group, it should propagate to all their jury memberships:

```typescript
async function propagateCOI(
  userId: string,
  projectId: string,
  competitionId: string,
  reason: string
): Promise<void> {
  // Find all jury groups this user is on
  const memberships = await getUserJuryGroups(userId, competitionId);

  for (const membership of memberships) {
    // Find assignments for this user+project in each jury group
    const assignments = await prisma.assignment.findMany({
      where: {
        userId,
        projectId,
        juryGroupId: membership.juryGroupId,
      },
    });

    for (const assignment of assignments) {
      // Check if COI already declared
      const existing = await prisma.conflictOfInterest.findUnique({
        where: { assignmentId: assignment.id },
      });

      if (!existing) {
        await prisma.conflictOfInterest.create({
          data: {
            assignmentId: assignment.id,
            reason: `Auto-propagated from ${membership.juryGroup.name}: ${reason}`,
            declared: true,
          },
        });
      }
    }
  }
}
```

---

## Jury Group Lifecycle

### States

```
DRAFT → ACTIVE → LOCKED → ARCHIVED
```

| State | Description | Operations Allowed |
|-------|-------------|-------------------|
| DRAFT | Being configured. Members can be added/removed freely | Add/remove members, edit settings |
| ACTIVE | Jury is in use. Assignments are being made or evaluations in progress | Add members (with warning), edit per-juror settings |
| LOCKED | Evaluation or voting is in progress. No membership changes | Edit per-juror notes only |
| ARCHIVED | Competition complete. Preserved for records | Read-only |

### State Transitions

```typescript
// Jury group activates when its linked round starts
async function activateJuryGroup(juryGroupId: string): Promise<void> {
  await prisma.juryGroup.update({
    where: { id: juryGroupId },
    data: { isActive: true },
  });
}

// Jury group locks when evaluation/voting begins
async function lockJuryGroup(juryGroupId: string): Promise<void> {
  // Prevent membership changes during active evaluation
  await prisma.juryGroup.update({
    where: { id: juryGroupId },
    data: { isActive: false }, // Using isActive as soft-lock; could add separate locked field
  });
}
```

---

## Onboarding

### Juror Onboarding Flow

When a juror is added to a JuryGroup, they go through an onboarding process:

1. **Invitation** — Admin adds juror to group → juror receives email invitation
2. **Profile Setup** — Juror fills out expertise tags, language preferences, category preference
3. **COI Pre-declaration** — Juror reviews project list and declares any pre-existing conflicts
4. **Confirmation** — Juror confirms they understand their role and responsibilities

### Onboarding UI

```
┌──────────────────────────────────────────────────────────────────┐
│  Welcome to Jury 1 — Semi-finalist Evaluation                    │
│                                                                  │
│  You've been selected to evaluate projects for the               │
│  Monaco Ocean Protection Challenge 2026.                         │
│                                                                  │
│  Step 1 of 3: Your Expertise                                    │
│  ─────────────────────────────────────────────────────────────   │
│                                                                  │
│  Select your areas of expertise (used for matching):             │
│  ☑ Marine Biology        ☑ Ocean Technology                     │
│  ☐ Renewable Energy      ☑ Environmental Policy                 │
│  ☐ Finance/Investment    ☐ Social Impact                        │
│  ☐ Data Science          ☐ Education                            │
│                                                                  │
│  Preferred languages:                                            │
│  ☑ English  ☑ French  ☐ Other: [________]                       │
│                                                                  │
│  Category preference (what % Startups vs Concepts):              │
│  Startups [====●=========] Concepts                              │
│            60% / 40%                                             │
│                                                                  │
│  [ Back ]                                        [ Next Step → ] │
└──────────────────────────────────────────────────────────────────┘
```

```
┌──────────────────────────────────────────────────────────────────┐
│  Step 2 of 3: Conflict of Interest Declaration                   │
│  ─────────────────────────────────────────────────────────────   │
│                                                                  │
│  Please review the project list and declare any conflicts        │
│  of interest. A COI exists if you have a personal,               │
│  financial, or professional relationship with a project team.    │
│                                                                  │
│  ┌──────────────────────────────────────┬──────────────────┐    │
│  │ Project                              │ COI?             │    │
│  ├──────────────────────────────────────┼──────────────────┤    │
│  │ OceanClean AI                        │ ○ None           │    │
│  │ DeepReef Monitoring                  │ ● COI Declared   │    │
│  │ CoralGuard                           │ ○ None           │    │
│  │ WaveEnergy Solutions                 │ ○ None           │    │
│  │ ... (60 more projects)               │                  │    │
│  └──────────────────────────────────────┴──────────────────┘    │
│                                                                  │
│  COI Details for "DeepReef Monitoring":                          │
│  ┌──────────────────────────────────────────────────────────┐   │
│  │ Former colleague of team lead. Worked together 2022-23.  │   │
│  └──────────────────────────────────────────────────────────┘   │
│                                                                  │
│  [ Back ]                                        [ Next Step → ] │
└──────────────────────────────────────────────────────────────────┘
```

```
┌──────────────────────────────────────────────────────────────────┐
│  Step 3 of 3: Confirmation                                       │
│  ─────────────────────────────────────────────────────────────   │
│                                                                  │
│  By confirming, you agree to:                                    │
│  ☑ Evaluate assigned projects fairly and impartially             │
│  ☑ Complete evaluations by the deadline                          │
│  ☑ Maintain confidentiality of all submissions                   │
│  ☑ Report any additional conflicts of interest                   │
│                                                                  │
│  Your assignments: up to 20 projects                             │
│  Evaluation deadline: March 15, 2026                             │
│  Category target: ~12 Startups / ~8 Concepts                    │
│                                                                  │
│  [ Back ]                             [ ✓ Confirm & Start ]      │
└──────────────────────────────────────────────────────────────────┘
```

---

## Admin Jury Management

### Jury Group Dashboard

```
┌──────────────────────────────────────────────────────────────────┐
│  Jury Groups — MOPC 2026                                         │
├──────────────────────────────────────────────────────────────────┤
│                                                                  │
│  ┌──────────────────────────────────────────────────────────┐   │
│  │ Jury 1 — Semi-finalist Selection                    [Edit]│   │
│  │ Members: 8 | Linked to: Round 3 | Status: ACTIVE         │   │
│  │ Cap: 20 (SOFT +2) | Avg load: 15.3 projects             │   │
│  │ ████████████████░░░░ 76% assignments complete            │   │
│  └──────────────────────────────────────────────────────────┘   │
│                                                                  │
│  ┌──────────────────────────────────────────────────────────┐   │
│  │ Jury 2 — Finalist Selection                         [Edit]│   │
│  │ Members: 6 | Linked to: Round 5 | Status: DRAFT          │   │
│  │ Cap: 15 (HARD) | Not yet assigned                        │   │
│  └──────────────────────────────────────────────────────────┘   │
│                                                                  │
│  ┌──────────────────────────────────────────────────────────┐   │
│  │ Jury 3 — Live Finals                                [Edit]│   │
│  │ Members: 5 | Linked to: Round 7, Round 8 | Status: DRAFT │   │
│  │ All finalists assigned to all jurors                      │   │
│  └──────────────────────────────────────────────────────────┘   │
│                                                                  │
│  ┌──────────────────────────────────────────────────────────┐   │
│  │ Innovation Award Jury                               [Edit]│   │
│  │ Members: 4 | Linked to: Innovation Award | Status: DRAFT │   │
│  │ Shares 2 members with Jury 2                             │   │
│  └──────────────────────────────────────────────────────────┘   │
│                                                                  │
│  [ + Create Jury Group ]                                         │
└──────────────────────────────────────────────────────────────────┘
```

### Member Management

```
┌──────────────────────────────────────────────────────────────────┐
│  Jury 1 — Member Management                                     │
├──────────────────────────────────────────────────────────────────┤
│                                                                  │
│  Group Defaults: Max 20 | SOFT cap (+2) | Quotas: S:5-12 C:5-12│
│                                                                  │
│  ┌──────┬──────────────┬──────┬─────┬──────┬──────┬──────────┐  │
│  │ Role │ Name         │ Load │ Cap │ S/C  │ Pref │ Actions  │  │
│  ├──────┼──────────────┼──────┼─────┼──────┼──────┼──────────┤  │
│  │ CHAIR│ Dr. Martin   │ 18   │ 20S │ 11/7 │ 60%  │ [Edit]   │  │
│  │ MEMBER│ Prof. Dubois│ 15   │ 20S │ 9/6  │ 50%  │ [Edit]   │  │
│  │ MEMBER│ Ms. Chen    │ 20   │ 20H │ 12/8 │ 60%  │ [Edit]   │  │
│  │ MEMBER│ Dr. Patel   │ 12   │ 15* │ 7/5  │ —    │ [Edit]   │  │
│  │ MEMBER│ Mr. Silva   │ 16   │ 20S │ 10/6 │ 70%  │ [Edit]   │  │
│  │ MEMBER│ Dr. Yamada  │ 19   │ 20S │ 11/8 │ 55%  │ [Edit]   │  │
│  │ MEMBER│ Ms. Hansen  │ 14   │ 20S │ 8/6  │ —    │ [Edit]   │  │
│  │ OBS  │ Mr. Berger   │ —    │ —   │ —    │ —    │ [Edit]   │  │
│  └──────┴──────────────┴──────┴─────┴──────┴──────┴──────────┘  │
│                                                                  │
│  * = per-juror override  S = SOFT  H = HARD                     │
│  S/C = Startup/Concept count  Pref = preferred startup ratio     │
│                                                                  │
│  [ + Add Member ]  [ Import from CSV ]  [ Run AI Assignment ]    │
└──────────────────────────────────────────────────────────────────┘
```

### Per-Juror Override Sheet

```
┌──────────────────────────────────────────────────────────────────┐
│  Edit Juror Settings — Dr. Patel                                 │
├──────────────────────────────────────────────────────────────────┤
│                                                                  │
│  Role: [MEMBER ▼]                                                │
│                                                                  │
│  ── Assignment Overrides ──────────────────────────────────────  │
│  (Leave blank to use group defaults)                             │
│                                                                  │
│  Max assignments: [15  ] (group default: 20)                     │
│  Cap mode: [HARD ▼] (group default: SOFT)                       │
│                                                                  │
│  Category quotas:                                                │
│    Startups:  min [3 ] max [10] (group: 5-12)                   │
│    Concepts:  min [3 ] max [8 ] (group: 5-12)                   │
│                                                                  │
│  ── Preferences ───────────────────────────────────────────────  │
│                                                                  │
│  Preferred startup ratio: [   ] % (blank = no preference)        │
│  Expertise tags: [marine-biology, policy, ...]                   │
│  Language: [English, French]                                     │
│                                                                  │
│  ── Notes ─────────────────────────────────────────────────────  │
│  ┌──────────────────────────────────────────────────────────┐   │
│  │ Dr. Patel requested reduced load due to conference       │   │
│  │ schedule in March. Hard cap at 15.                       │   │
│  └──────────────────────────────────────────────────────────┘   │
│                                                                  │
│  [ Cancel ]                                    [ Save Changes ]  │
└──────────────────────────────────────────────────────────────────┘
```

---

## Integration with Assignment Algorithm

The assignment algorithm (see `06-round-evaluation.md`) uses JuryGroup data at every step:

### Algorithm Input

```typescript
type AssignmentInput = {
  roundId: string;
  juryGroupId: string;
  projects: Project[];
  config: {
    requiredReviewsPerProject: number;
  };
};
```

### Algorithm Steps Using JuryGroup

1. **Load jury members** — Fetch all active JuryGroupMembers with role != OBSERVER
2. **Resolve effective limits** — For each member, compute effective cap and quotas
3. **Filter by COI** — Exclude members with declared COI for each project
4. **Score candidates** — For each (project, juror) pair, compute:
   - Tag overlap score (expertise alignment)
   - Workload balance score (prefer jurors with fewer assignments)
   - Category ratio alignment score (prefer assignment that brings ratio closer to preference)
   - Geo-diversity score
5. **Apply caps** — Skip jurors who have reached their effective cap
6. **Apply quotas** — Skip jurors who have reached category max
7. **Rank and assign** — Greedily assign top-scoring pairs
8. **Validate minimums** — Check if category minimums are met; warn admin if not

### Assignment Preview

```typescript
type AssignmentPreview = {
  assignments: {
    userId: string;
    projectId: string;
    score: number;
    breakdown: {
      tagOverlap: number;
      workloadBalance: number;
      ratioAlignment: number;
      geoDiversity: number;
    };
  }[];

  warnings: {
    type: 'CAP_EXCEEDED' | 'QUOTA_UNMET' | 'COI_SKIP' | 'UNASSIGNED_PROJECT';
    message: string;
    userId?: string;
    projectId?: string;
  }[];

  stats: {
    totalAssignments: number;
    avgLoadPerJuror: number;
    minLoad: number;
    maxLoad: number;
    unassignedProjects: number;
    categoryDistribution: Record<string, { avg: number; min: number; max: number }>;
  };
};
```

---

## API Procedures

### New tRPC Router: jury-group.ts

```typescript
export const juryGroupRouter = router({
  // ── CRUD ───────────────────────────────────────────────

  /** Create a new jury group */
  create: adminProcedure
    .input(z.object({
      competitionId: z.string(),
      name: z.string().min(1).max(100),
      description: z.string().optional(),
      defaultMaxAssignments: z.number().int().min(1).default(20),
      defaultCapMode: z.enum(['HARD', 'SOFT', 'NONE']).default('SOFT'),
      softCapBuffer: z.number().int().min(0).default(2),
      defaultCategoryQuotas: z.record(z.object({
        min: z.number().int().min(0),
        max: z.number().int().min(0),
      })).optional(),
    }))
    .mutation(async ({ input }) => { ... }),

  /** Update jury group settings */
  update: adminProcedure
    .input(z.object({
      juryGroupId: z.string(),
      name: z.string().min(1).max(100).optional(),
      description: z.string().optional(),
      defaultMaxAssignments: z.number().int().min(1).optional(),
      defaultCapMode: z.enum(['HARD', 'SOFT', 'NONE']).optional(),
      softCapBuffer: z.number().int().min(0).optional(),
      defaultCategoryQuotas: z.record(z.object({
        min: z.number().int().min(0),
        max: z.number().int().min(0),
      })).nullable().optional(),
    }))
    .mutation(async ({ input }) => { ... }),

  /** Delete jury group (only if DRAFT and no assignments) */
  delete: adminProcedure
    .input(z.object({ juryGroupId: z.string() }))
    .mutation(async ({ input }) => { ... }),

  /** Get jury group with members */
  getById: protectedProcedure
    .input(z.object({ juryGroupId: z.string() }))
    .query(async ({ input }) => { ... }),

  /** List all jury groups for a competition */
  listByCompetition: protectedProcedure
    .input(z.object({ competitionId: z.string() }))
    .query(async ({ input }) => { ... }),

  // ── Members ────────────────────────────────────────────

  /** Add a member to the jury group */
  addMember: adminProcedure
    .input(z.object({
      juryGroupId: z.string(),
      userId: z.string(),
      role: z.enum(['MEMBER', 'CHAIR', 'OBSERVER']).default('MEMBER'),
    }))
    .mutation(async ({ input }) => { ... }),

  /** Remove a member from the jury group */
  removeMember: adminProcedure
    .input(z.object({
      juryGroupId: z.string(),
      userId: z.string(),
    }))
    .mutation(async ({ input }) => { ... }),

  /** Batch add members (from CSV or user selection) */
  addMembersBatch: adminProcedure
    .input(z.object({
      juryGroupId: z.string(),
      members: z.array(z.object({
        userId: z.string(),
        role: z.enum(['MEMBER', 'CHAIR', 'OBSERVER']).default('MEMBER'),
      })),
    }))
    .mutation(async ({ input }) => { ... }),

  /** Update member settings (overrides, preferences) */
  updateMember: adminProcedure
    .input(z.object({
      juryGroupId: z.string(),
      userId: z.string(),
      role: z.enum(['MEMBER', 'CHAIR', 'OBSERVER']).optional(),
      maxAssignmentsOverride: z.number().int().min(1).nullable().optional(),
      capModeOverride: z.enum(['HARD', 'SOFT', 'NONE']).nullable().optional(),
      categoryQuotasOverride: z.record(z.object({
        min: z.number().int().min(0),
        max: z.number().int().min(0),
      })).nullable().optional(),
      preferredStartupRatio: z.number().min(0).max(1).nullable().optional(),
      expertiseTags: z.array(z.string()).optional(),
      languagePreferences: z.array(z.string()).optional(),
      notes: z.string().nullable().optional(),
    }))
    .mutation(async ({ input }) => { ... }),

  // ── Queries ────────────────────────────────────────────

  /** Get all jury groups a user belongs to */
  getMyJuryGroups: juryProcedure
    .query(async ({ ctx }) => { ... }),

  /** Get assignment stats for a jury group */
  getAssignmentStats: adminProcedure
    .input(z.object({ juryGroupId: z.string() }))
    .query(async ({ input }) => { ... }),

  /** Check if a user can be added (no duplicate, role compatible) */
  checkMemberEligibility: adminProcedure
    .input(z.object({
      juryGroupId: z.string(),
      userId: z.string(),
    }))
    .query(async ({ input }) => { ... }),

  // ── Onboarding ─────────────────────────────────────────

  /** Get onboarding status for a juror */
  getOnboardingStatus: juryProcedure
    .input(z.object({ juryGroupId: z.string() }))
    .query(async ({ ctx, input }) => { ... }),

  /** Submit onboarding form (preferences, COI declarations) */
  submitOnboarding: juryProcedure
    .input(z.object({
      juryGroupId: z.string(),
      expertiseTags: z.array(z.string()),
      languagePreferences: z.array(z.string()),
      preferredStartupRatio: z.number().min(0).max(1).optional(),
      coiDeclarations: z.array(z.object({
        projectId: z.string(),
        reason: z.string(),
      })),
    }))
    .mutation(async ({ ctx, input }) => { ... }),
});
```

---

## Service Functions

```typescript
// src/server/services/jury-group.ts

/** Create a jury group with defaults */
export async function createJuryGroup(
  competitionId: string,
  name: string,
  config?: Partial<JuryGroupConfig>
): Promise<JuryGroup>;

/** Get effective limits for a member (resolved overrides) */
export async function getEffectiveLimits(
  member: JuryGroupMember,
  group: JuryGroup
): Promise<{ maxAssignments: number | null; capMode: CapMode; quotas: CategoryQuotas | null }>;

/** Check if a juror can receive more assignments */
export async function canAssignMore(
  userId: string,
  juryGroupId: string,
  category?: CompetitionCategory
): Promise<{ allowed: boolean; reason?: string }>;

/** Get assignment statistics for the whole group */
export async function getGroupAssignmentStats(
  juryGroupId: string
): Promise<GroupStats>;

/** Propagate COI across all jury groups for a user */
export async function propagateCOI(
  userId: string,
  projectId: string,
  competitionId: string,
  reason: string
): Promise<void>;

/** Get all active members (excluding observers) for assignment */
export async function getAssignableMembers(
  juryGroupId: string
): Promise<JuryGroupMember[]>;

/** Validate group readiness (enough members, all onboarded, etc.) */
export async function validateGroupReadiness(
  juryGroupId: string
): Promise<{ ready: boolean; issues: string[] }>;
```

---

## Edge Cases

| Scenario | Handling |
|----------|----------|
| **Juror added to group during active evaluation** | Allowed with admin warning. New juror gets no existing assignments (must run assignment again) |
| **Juror removed from group during active evaluation** | Blocked if juror has pending evaluations. Must reassign first |
| **All jurors at cap but projects remain unassigned** | Warning shown to admin. Suggest increasing caps or adding jurors |
| **Category quota min not met for any juror** | Warning shown in assignment preview. Admin can proceed or adjust |
| **Juror on 3+ jury groups** | Supported. Each membership independent. Cross-jury COI propagation ensures consistency |
| **Jury Chair also has assignments** | Allowed. Chair is a regular evaluator with extra visibility |
| **Observer tries to submit evaluation** | Blocked at procedure level (OBSERVER role excluded from evaluation mutations) |
| **Admin deletes jury group with active assignments** | Blocked. Must complete or reassign all assignments first |
| **Juror preference ratio impossible** | (e.g., 90% startups but only 20% projects are startups) — Warn in onboarding, treat as best-effort |
| **Same user added twice to same group** | Blocked by unique constraint on [juryGroupId, userId] |

---

## Integration Points

### Inbound

| Source | Data | Purpose |
|--------|------|---------|
| Competition setup wizard | Group config | Create jury groups during competition setup |
| User management | User records | Add jurors as members |
| COI declarations | Conflict records | Filter assignments, propagate across groups |

### Outbound

| Target | Data | Purpose |
|--------|------|---------|
| Assignment algorithm | Members, caps, quotas | Generate assignments |
| Evaluation rounds | Jury membership | Determine who evaluates what |
| Live finals | Jury 3 members | Live voting access |
| Confirmation round | Jury members | Who must approve winner proposal |
| Special awards | Award jury members | Award evaluation access |
| Notifications | Member list | Send round-specific emails to jury |

### JuryGroup → Round Linkage

Each evaluation or live-final round links to exactly one JuryGroup:

```prisma
model Round {
  // ...
  juryGroupId     String?
  juryGroup       JuryGroup? @relation(...)
}
```

This means:
- Round 3 (EVALUATION) → Jury 1
- Round 5 (EVALUATION) → Jury 2
- Round 7 (LIVE_FINAL) → Jury 3
- Round 8 (CONFIRMATION) → Jury 3 (same group, different round)

A jury group can be linked to multiple rounds (e.g., Jury 3 handles both live finals and confirmation).
