MOPC-App/docs/claude-architecture-redesign/23-implementation-sequence.md

76 KiB

Implementation Sequence: MOPC Architecture Redesign

Overview

This document defines the complete implementation sequence for the MOPC architecture redesign, broken into 7 sequential phases with clear dependencies, acceptance criteria, and estimated complexity. The redesign eliminates the Pipeline->Track->Stage abstraction in favor of a streamlined Competition->Round model with explicit jury groups, multi-round submissions, mentoring workspace, and winner confirmation.

Phasing Principles

  1. Incremental value — Each phase delivers testable, self-contained functionality
  2. Backward compatibility — New system runs alongside old until Phase 6
  3. Feature flags — Gradual rollout controlled by flags
  4. Clear acceptance gates — No phase progresses without passing criteria
  5. Parallel work streams — Phases overlap where dependencies allow
  6. Rollback points — Can stop and revert at any phase boundary

Duration Estimates

Phase Name Duration Dependencies Complexity
Phase 0 Contract Freeze & Preparation Week 1 None Low
Phase 1 Schema & Runtime Foundation Weeks 2-3 Phase 0 High
Phase 2 Backend Orchestration Weeks 3-5 Phase 1 High
Phase 3 Admin Control Plane Weeks 4-6 Phase 1, partial Phase 2 High
Phase 4 Participant Journeys Weeks 5-7 Phase 2, Phase 3 Medium
Phase 5 Special Awards & Governance Weeks 6-7 Phase 2, Phase 4 Medium
Phase 6 Platform-Wide Refit Weeks 7-8 All previous High
Phase 7 Validation & Release Weeks 8-9 All previous Medium

Total: 8-9 weeks


Dependency Graph

Phase 0: Contract Freeze & Preparation
    │
    ▼
Phase 1: Schema & Runtime Foundation ──────────────┐
    │                                               │
    ├──► Phase 2: Backend Orchestration             │
    │        │                                      │
    │        ├──► Phase 5: Special Awards           │
    │        │         │                            │
    │        │         ▼                            │
    ▼        ▼         │                            │
Phase 3: Admin Control Plane                       │
    │        │         │                            │
    │        ▼         │                            │
    └──► Phase 4: Participant Journeys              │
             │         │                            │
             ▼         ▼                            │
         Phase 6: Platform-Wide Refit ◄─────────────┘
             │
             ▼
         Phase 7: Validation & Release

Critical Path

Critical path (delays here delay the entire project):

  1. Phase 0 → Phase 1 → Phase 2 → Phase 6 → Phase 7

Parallel paths (can progress independently):

  • Phase 3 (admin UI) can start once Phase 1 is complete
  • Phase 4 (participant UI) needs Phase 2 + Phase 3
  • Phase 5 (awards) needs Phase 2 but can run parallel to Phase 4

Phase 0: Contract Freeze & Preparation

Week 1

Goals

  1. Finalize all TypeScript type definitions and Zod schemas
  2. Create feature flags for gradual rollout
  3. Set up test infrastructure for new models
  4. Document all API contracts
  5. Prepare development environment

Tasks

0.1 Type System Preparation

Files to Create:

  • src/types/competition.ts — Competition types
  • src/types/round.ts — Round types and RoundType enum
  • src/types/round-configs.ts — All round config shapes (Intake, Filtering, Evaluation, Submission, Mentoring, LiveFinal, Confirmation)
  • src/types/jury-group.ts — JuryGroup and JuryGroupMember types
  • src/types/submission-window.ts — SubmissionWindow types
  • src/types/winner-confirmation.ts — WinnerProposal and WinnerApproval types
  • src/types/advancement-rule.ts — AdvancementRule types

Tasks:

// 0.1.1 Define all enums
export enum RoundType {
  INTAKE = "INTAKE",
  FILTERING = "FILTERING",
  EVALUATION = "EVALUATION",
  SUBMISSION = "SUBMISSION",
  MENTORING = "MENTORING",
  LIVE_FINAL = "LIVE_FINAL",
  CONFIRMATION = "CONFIRMATION",
}

export enum RoundStatus {
  ROUND_DRAFT = "ROUND_DRAFT",
  ROUND_ACTIVE = "ROUND_ACTIVE",
  ROUND_CLOSED = "ROUND_CLOSED",
  ROUND_ARCHIVED = "ROUND_ARCHIVED",
}

export enum CompetitionStatus {
  DRAFT = "DRAFT",
  ACTIVE = "ACTIVE",
  CLOSED = "CLOSED",
  ARCHIVED = "ARCHIVED",
}

// 0.1.2 Define round config shapes with Zod validation
export const IntakeConfigSchema = z.object({
  allowDrafts: z.boolean(),
  draftExpiryDays: z.number(),
  acceptedCategories: z.array(z.enum(["STARTUP", "BUSINESS_CONCEPT"])),
  publicFormEnabled: z.boolean(),
  customFields: z.array(CustomFieldSchema),
});

export const FilteringConfigSchema = z.object({
  rules: z.array(FilterRuleDefSchema),
  aiScreeningEnabled: z.boolean(),
  aiCriteriaText: z.string(),
  aiConfidenceThresholds: z.object({
    high: z.number(),
    medium: z.number(),
    low: z.number(),
  }),
  manualReviewEnabled: z.boolean(),
  batchSize: z.number(),
});

export const EvaluationConfigSchema = z.object({
  requiredReviewsPerProject: z.number(),
  scoringMode: z.enum(["criteria", "global", "binary"]),
  requireFeedback: z.boolean(),
  coiRequired: z.boolean(),
  peerReviewEnabled: z.boolean(),
  anonymizationLevel: z.enum(["fully_anonymous", "show_initials", "named"]),
  aiSummaryEnabled: z.boolean(),
  advancementMode: z.enum(["auto_top_n", "admin_selection", "ai_recommended"]),
  advancementConfig: z.object({
    perCategory: z.boolean(),
    startupCount: z.number(),
    conceptCount: z.number(),
    tieBreaker: z.enum(["admin_decides", "highest_individual", "revote"]),
  }),
});

export const SubmissionConfigSchema = z.object({
  eligibleStatuses: z.array(z.enum(["PASSED", "PENDING", "IN_PROGRESS"])),
  notifyEligibleTeams: z.boolean(),
  lockPreviousWindows: z.boolean(),
});

export const MentoringConfigSchema = z.object({
  eligibility: z.enum(["all_advancing", "requested_only"]),
  chatEnabled: z.boolean(),
  fileUploadEnabled: z.boolean(),
  fileCommentsEnabled: z.boolean(),
  filePromotionEnabled: z.boolean(),
  promotionTargetWindowId: z.string().nullable(),
  autoAssignMentors: z.boolean(),
});

export const LiveFinalConfigSchema = z.object({
  juryVotingEnabled: z.boolean(),
  votingMode: z.enum(["simple", "criteria"]),
  audienceVotingEnabled: z.boolean(),
  audienceVoteWeight: z.number(),
  audienceVotingMode: z.enum(["per_project", "per_category", "favorites"]),
  audienceMaxFavorites: z.number().nullable(),
  audienceRequireIdentification: z.boolean(),
  deliberationEnabled: z.boolean(),
  deliberationDurationMinutes: z.number(),
  showAudienceVotesToJury: z.boolean(),
  presentationOrderMode: z.enum(["manual", "random", "score_based"]),
  revealPolicy: z.enum(["immediate", "delayed", "ceremony"]),
});

export const ConfirmationConfigSchema = z.object({
  requireAllJuryApproval: z.boolean(),
  juryGroupId: z.string().nullable(),
  adminOverrideEnabled: z.boolean(),
  overrideModes: z.array(z.enum(["FORCE_MAJORITY", "ADMIN_DECISION"])),
  autoFreezeOnApproval: z.boolean(),
  perCategory: z.boolean(),
});

// 0.1.3 Config union type with discriminated union
export const RoundConfigSchema = z.discriminatedUnion("roundType", [
  z.object({ roundType: z.literal(RoundType.INTAKE), config: IntakeConfigSchema }),
  z.object({ roundType: z.literal(RoundType.FILTERING), config: FilteringConfigSchema }),
  z.object({ roundType: z.literal(RoundType.EVALUATION), config: EvaluationConfigSchema }),
  z.object({ roundType: z.literal(RoundType.SUBMISSION), config: SubmissionConfigSchema }),
  z.object({ roundType: z.literal(RoundType.MENTORING), config: MentoringConfigSchema }),
  z.object({ roundType: z.literal(RoundType.LIVE_FINAL), config: LiveFinalConfigSchema }),
  z.object({ roundType: z.literal(RoundType.CONFIRMATION), config: ConfirmationConfigSchema }),
]);

0.2 Feature Flags Setup

File to Create:

  • src/lib/feature-flags.ts
export const FEATURE_FLAGS = {
  // Phase 1
  ENABLE_COMPETITION_MODEL: false,
  ENABLE_ROUND_MODEL: false,
  ENABLE_JURY_GROUPS: false,
  ENABLE_SUBMISSION_WINDOWS: false,

  // Phase 2
  ENABLE_ENHANCED_ASSIGNMENT: false,
  ENABLE_MENTOR_WORKSPACE: false,
  ENABLE_WINNER_CONFIRMATION: false,

  // Phase 3
  ENABLE_COMPETITION_WIZARD: false,
  ENABLE_ROUND_MANAGEMENT_UI: false,

  // Phase 4
  ENABLE_MULTI_ROUND_APPLICANT_UI: false,
  ENABLE_MENTOR_UI: false,

  // Phase 5
  ENABLE_ENHANCED_AWARDS: false,

  // Phase 6
  REMOVE_LEGACY_MODELS: false,
} as const;

export function isFeatureEnabled(flag: keyof typeof FEATURE_FLAGS): boolean {
  return FEATURE_FLAGS[flag] || process.env.NODE_ENV === "test";
}

0.3 Test Infrastructure

Files to Create:

  • tests/helpers-redesign.ts — New factories for Competition, Round, JuryGroup, etc.
import { prisma } from "./setup";

// Factory: Competition
export async function createTestCompetition(programId: string, overrides?: Partial<Competition>) {
  return prisma.competition.create({
    data: {
      programId,
      name: overrides?.name || "Test Competition 2026",
      slug: overrides?.slug || `comp-${uid()}`,
      status: overrides?.status || "DRAFT",
      categoryMode: overrides?.categoryMode || "SHARED",
      startupFinalistCount: overrides?.startupFinalistCount || 3,
      conceptFinalistCount: overrides?.conceptFinalistCount || 3,
      ...overrides,
    },
  });
}

// Factory: Round
export async function createTestRound(competitionId: string, roundType: RoundType, overrides?: Partial<Round>) {
  return prisma.round.create({
    data: {
      competitionId,
      name: overrides?.name || `Test ${roundType} Round`,
      slug: overrides?.slug || `round-${uid()}`,
      roundType,
      status: overrides?.status || "ROUND_DRAFT",
      sortOrder: overrides?.sortOrder || 0,
      ...overrides,
    },
  });
}

// Factory: JuryGroup
export async function createTestJuryGroup(competitionId: string, overrides?: Partial<JuryGroup>) {
  return prisma.juryGroup.create({
    data: {
      competitionId,
      name: overrides?.name || "Test Jury Group",
      slug: overrides?.slug || `jury-${uid()}`,
      defaultMaxAssignments: overrides?.defaultMaxAssignments || 20,
      defaultCapMode: overrides?.defaultCapMode || "SOFT",
      softCapBuffer: overrides?.softCapBuffer || 2,
      ...overrides,
    },
  });
}

// Factory: JuryGroupMember
export async function createTestJuryMember(juryGroupId: string, userId: string, overrides?: Partial<JuryGroupMember>) {
  return prisma.juryGroupMember.create({
    data: {
      juryGroupId,
      userId,
      isLead: overrides?.isLead || false,
      ...overrides,
    },
  });
}

// Factory: SubmissionWindow
export async function createTestSubmissionWindow(competitionId: string, overrides?: Partial<SubmissionWindow>) {
  return prisma.submissionWindow.create({
    data: {
      competitionId,
      name: overrides?.name || "Test Submission Window",
      slug: overrides?.slug || `window-${uid()}`,
      roundNumber: overrides?.roundNumber || 1,
      sortOrder: overrides?.sortOrder || 0,
      deadlinePolicy: overrides?.deadlinePolicy || "FLAG",
      lockOnClose: overrides?.lockOnClose ?? true,
      ...overrides,
    },
  });
}

// Factory: ProjectRoundState
export async function createTestProjectRoundState(projectId: string, roundId: string, state: ProjectRoundStateValue) {
  return prisma.projectRoundState.create({
    data: {
      projectId,
      roundId,
      state,
    },
  });
}

// Factory: WinnerProposal
export async function createTestWinnerProposal(competitionId: string, category: CompetitionCategory, overrides?: Partial<WinnerProposal>) {
  return prisma.winnerProposal.create({
    data: {
      competitionId,
      category,
      status: overrides?.status || "PENDING",
      rankedProjectIds: overrides?.rankedProjectIds || [],
      sourceRoundId: overrides?.sourceRoundId || "",
      selectionBasis: overrides?.selectionBasis || {},
      proposedById: overrides?.proposedById || "",
      ...overrides,
    },
  });
}

// Cleanup helper (add to existing cleanupTestData)
export async function cleanupTestDataRedesign() {
  await prisma.winnerApproval.deleteMany();
  await prisma.winnerProposal.deleteMany();
  await prisma.projectRoundState.deleteMany();
  await prisma.advancementRule.deleteMany();
  await prisma.juryGroupMember.deleteMany();
  await prisma.juryGroup.deleteMany();
  await prisma.submissionFileRequirement.deleteMany();
  await prisma.roundSubmissionVisibility.deleteMany();
  await prisma.submissionWindow.deleteMany();
  await prisma.round.deleteMany();
  await prisma.competition.deleteMany();
}

0.4 API Contract Documentation

File to Create:

  • docs/claude-architecture-redesign/api-contracts.md

Document all new tRPC endpoints with input/output shapes (covered in doc 19).

Acceptance Criteria

  • All TypeScript types compile without errors
  • All Zod schemas validate correctly
  • Feature flags file exists and all flags default to false
  • Test helper factories exist for all new models
  • API contract documentation complete
  • Development environment runs without issues
  • All team members have reviewed and approved contracts

Deliverables

  1. src/types/ directory with all new type definitions
  2. src/lib/feature-flags.ts with all flags
  3. tests/helpers-redesign.ts with factory functions
  4. API contract documentation
  5. Updated CLAUDE.md with redesign context

Risks & Mitigations

Risk Impact Mitigation
Type definitions change during implementation High Lock contracts via code review + sign-off
Test helpers incomplete Medium Review against data model doc systematically
Feature flag conflicts Low Namespace with ENABLE_ prefix

Phase 1: Schema & Runtime Foundation

Weeks 2-3

Goals

  1. Prisma schema migration — add all new tables (Phase 1 of migration)
  2. Competition model CRUD
  3. Round model CRUD (basic, no type-specific logic yet)
  4. JuryGroup and JuryGroupMember CRUD
  5. SubmissionWindow CRUD
  6. ProjectRoundState basic operations
  7. Round engine (simplified state machine)

Dependencies

  • Phase 0 complete

Tasks

1.1 Prisma Schema Migration (Phase 1 — Additive Only)

Files to Modify:

  • prisma/schema.prisma

Tasks:

# 1.1.1 Create migration
npx prisma migrate dev --name add_competition_round_jury_models --create-only

# 1.1.2 Review generated migration SQL
# Verify:
# - New tables created: Competition, Round, JuryGroup, JuryGroupMember,
#   SubmissionWindow, SubmissionFileRequirement, RoundSubmissionVisibility,
#   ProjectRoundState, AdvancementRule, WinnerProposal, WinnerApproval,
#   MentorFile, MentorFileComment
# - New columns added to existing tables:
#   - Assignment.juryGroupId, Assignment.roundId (alongside stageId)
#   - ProjectFile.submissionWindowId, ProjectFile.requirementId
#   - Project.competitionId
#   - SpecialAward.competitionId, evaluationRoundId, juryGroupId, eligibilityMode
#   - MentorAssignment.workspaceEnabled, workspaceOpenAt, workspaceCloseAt
# - New enums: RoundType, RoundStatus, CompetitionStatus, CapMode, DeadlinePolicy,
#   ProjectRoundStateValue, AdvancementRuleType, AwardEligibilityMode,
#   WinnerProposalStatus, WinnerApprovalRole

# 1.1.3 Apply migration
npx prisma migrate dev

# 1.1.4 Regenerate Prisma client
npx prisma generate

Schema Changes:

Add all models from doc 03-data-model.md:

  • Competition (with enums)
  • Round (with enums)
  • JuryGroup
  • JuryGroupMember
  • SubmissionWindow
  • SubmissionFileRequirement
  • RoundSubmissionVisibility
  • ProjectRoundState (with enums)
  • AdvancementRule (with enums)
  • WinnerProposal (with enums)
  • WinnerApproval (with enums)
  • MentorFile
  • MentorFileComment

Modify existing models:

  • Assignment: add juryGroupId, roundId (keep stageId for now)
  • ProjectFile: add submissionWindowId, requirementId
  • Project: add competitionId
  • SpecialAward: add competitionId, evaluationRoundId, juryGroupId, eligibilityMode
  • MentorAssignment: add workspaceEnabled, workspaceOpenAt, workspaceCloseAt
  • User: add relations for jury memberships, mentor files, winner proposals/approvals

1.2 Competition CRUD Router

File to Create:

  • src/server/routers/competition.ts
import { router, protectedProcedure, adminProcedure } from "../trpc";
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { isFeatureEnabled } from "@/lib/feature-flags";

export const competitionRouter = router({
  // Create competition
  create: adminProcedure
    .input(
      z.object({
        programId: z.string(),
        name: z.string(),
        slug: z.string(),
        categoryMode: z.enum(["SHARED", "SPLIT"]).default("SHARED"),
        startupFinalistCount: z.number().default(3),
        conceptFinalistCount: z.number().default(3),
        notifyOnRoundAdvance: z.boolean().default(true),
        notifyOnDeadlineApproach: z.boolean().default(true),
        deadlineReminderDays: z.array(z.number()).default([7, 3, 1]),
      })
    )
    .mutation(async ({ ctx, input }) => {
      if (!isFeatureEnabled("ENABLE_COMPETITION_MODEL")) {
        throw new TRPCError({ code: "FORBIDDEN", message: "Feature not enabled" });
      }

      return ctx.prisma.competition.create({
        data: input,
      });
    }),

  // List competitions for a program
  listByProgram: protectedProcedure
    .input(z.object({ programId: z.string() }))
    .query(async ({ ctx, input }) => {
      if (!isFeatureEnabled("ENABLE_COMPETITION_MODEL")) {
        return [];
      }

      return ctx.prisma.competition.findMany({
        where: { programId: input.programId },
        include: {
          rounds: { orderBy: { sortOrder: "asc" } },
          juryGroups: true,
          submissionWindows: { orderBy: { sortOrder: "asc" } },
          specialAwards: true,
        },
        orderBy: { createdAt: "desc" },
      });
    }),

  // Get competition by ID
  getById: protectedProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ ctx, input }) => {
      if (!isFeatureEnabled("ENABLE_COMPETITION_MODEL")) {
        throw new TRPCError({ code: "NOT_FOUND" });
      }

      const competition = await ctx.prisma.competition.findUnique({
        where: { id: input.id },
        include: {
          program: true,
          rounds: { orderBy: { sortOrder: "asc" } },
          juryGroups: { include: { members: { include: { user: true } } } },
          submissionWindows: { orderBy: { sortOrder: "asc" } },
          specialAwards: true,
        },
      });

      if (!competition) {
        throw new TRPCError({ code: "NOT_FOUND", message: "Competition not found" });
      }

      return competition;
    }),

  // Update competition
  update: adminProcedure
    .input(
      z.object({
        id: z.string(),
        name: z.string().optional(),
        status: z.enum(["DRAFT", "ACTIVE", "CLOSED", "ARCHIVED"]).optional(),
        categoryMode: z.enum(["SHARED", "SPLIT"]).optional(),
        startupFinalistCount: z.number().optional(),
        conceptFinalistCount: z.number().optional(),
        notifyOnRoundAdvance: z.boolean().optional(),
        notifyOnDeadlineApproach: z.boolean().optional(),
        deadlineReminderDays: z.array(z.number()).optional(),
      })
    )
    .mutation(async ({ ctx, input }) => {
      if (!isFeatureEnabled("ENABLE_COMPETITION_MODEL")) {
        throw new TRPCError({ code: "FORBIDDEN", message: "Feature not enabled" });
      }

      const { id, ...data } = input;

      return ctx.prisma.competition.update({
        where: { id },
        data,
      });
    }),

  // Delete competition
  delete: adminProcedure
    .input(z.object({ id: z.string() }))
    .mutation(async ({ ctx, input }) => {
      if (!isFeatureEnabled("ENABLE_COMPETITION_MODEL")) {
        throw new TRPCError({ code: "FORBIDDEN", message: "Feature not enabled" });
      }

      return ctx.prisma.competition.delete({
        where: { id: input.id },
      });
    }),
});

1.3 Round CRUD Router

File to Create:

  • src/server/routers/round.ts
import { router, protectedProcedure, adminProcedure } from "../trpc";
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { isFeatureEnabled } from "@/lib/feature-flags";
import { RoundType, RoundStatus } from "@prisma/client";

export const roundRouter = router({
  // Create round
  create: adminProcedure
    .input(
      z.object({
        competitionId: z.string(),
        name: z.string(),
        slug: z.string(),
        roundType: z.nativeEnum(RoundType),
        sortOrder: z.number(),
        windowOpenAt: z.date().optional(),
        windowCloseAt: z.date().optional(),
        configJson: z.any().optional(), // Validated per roundType
        juryGroupId: z.string().optional(),
        submissionWindowId: z.string().optional(),
      })
    )
    .mutation(async ({ ctx, input }) => {
      if (!isFeatureEnabled("ENABLE_ROUND_MODEL")) {
        throw new TRPCError({ code: "FORBIDDEN", message: "Feature not enabled" });
      }

      return ctx.prisma.round.create({
        data: input,
      });
    }),

  // List rounds for competition
  listByCompetition: protectedProcedure
    .input(z.object({ competitionId: z.string() }))
    .query(async ({ ctx, input }) => {
      if (!isFeatureEnabled("ENABLE_ROUND_MODEL")) {
        return [];
      }

      return ctx.prisma.round.findMany({
        where: { competitionId: input.competitionId },
        include: {
          juryGroup: { include: { members: { include: { user: true } } } },
          submissionWindow: { include: { fileRequirements: true } },
          projectRoundStates: { include: { project: true } },
          visibleSubmissionWindows: { include: { submissionWindow: true } },
        },
        orderBy: { sortOrder: "asc" },
      });
    }),

  // Get round by ID
  getById: protectedProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ ctx, input }) => {
      if (!isFeatureEnabled("ENABLE_ROUND_MODEL")) {
        throw new TRPCError({ code: "NOT_FOUND" });
      }

      const round = await ctx.prisma.round.findUnique({
        where: { id: input.id },
        include: {
          competition: true,
          juryGroup: { include: { members: { include: { user: true } } } },
          submissionWindow: { include: { fileRequirements: true } },
          projectRoundStates: { include: { project: true } },
          advancementRules: true,
          visibleSubmissionWindows: { include: { submissionWindow: true } },
        },
      });

      if (!round) {
        throw new TRPCError({ code: "NOT_FOUND", message: "Round not found" });
      }

      return round;
    }),

  // Update round
  update: adminProcedure
    .input(
      z.object({
        id: z.string(),
        name: z.string().optional(),
        status: z.nativeEnum(RoundStatus).optional(),
        windowOpenAt: z.date().optional(),
        windowCloseAt: z.date().optional(),
        configJson: z.any().optional(),
        juryGroupId: z.string().optional(),
        submissionWindowId: z.string().optional(),
      })
    )
    .mutation(async ({ ctx, input }) => {
      if (!isFeatureEnabled("ENABLE_ROUND_MODEL")) {
        throw new TRPCError({ code: "FORBIDDEN", message: "Feature not enabled" });
      }

      const { id, ...data } = input;

      return ctx.prisma.round.update({
        where: { id },
        data,
      });
    }),

  // Delete round
  delete: adminProcedure
    .input(z.object({ id: z.string() }))
    .mutation(async ({ ctx, input }) => {
      if (!isFeatureEnabled("ENABLE_ROUND_MODEL")) {
        throw new TRPCError({ code: "FORBIDDEN", message: "Feature not enabled" });
      }

      return ctx.prisma.round.delete({
        where: { id: input.id },
      });
    }),
});

1.4 JuryGroup CRUD Router

File to Create:

  • src/server/routers/jury-group.ts
import { router, protectedProcedure, adminProcedure } from "../trpc";
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { isFeatureEnabled } from "@/lib/feature-flags";
import { CapMode } from "@prisma/client";

export const juryGroupRouter = router({
  // Create jury group
  create: adminProcedure
    .input(
      z.object({
        competitionId: z.string(),
        name: z.string(),
        slug: z.string(),
        description: z.string().optional(),
        sortOrder: z.number().default(0),
        defaultMaxAssignments: z.number().default(20),
        defaultCapMode: z.nativeEnum(CapMode).default("SOFT"),
        softCapBuffer: z.number().default(2),
        categoryQuotasEnabled: z.boolean().default(false),
        defaultCategoryQuotas: z.any().optional(),
        allowJurorCapAdjustment: z.boolean().default(false),
        allowJurorRatioAdjustment: z.boolean().default(false),
      })
    )
    .mutation(async ({ ctx, input }) => {
      if (!isFeatureEnabled("ENABLE_JURY_GROUPS")) {
        throw new TRPCError({ code: "FORBIDDEN", message: "Feature not enabled" });
      }

      return ctx.prisma.juryGroup.create({
        data: input,
      });
    }),

  // Add member to jury group
  addMember: adminProcedure
    .input(
      z.object({
        juryGroupId: z.string(),
        userId: z.string(),
        isLead: z.boolean().default(false),
        maxAssignmentsOverride: z.number().optional(),
        capModeOverride: z.nativeEnum(CapMode).optional(),
        categoryQuotasOverride: z.any().optional(),
        preferredStartupRatio: z.number().optional(),
        availabilityNotes: z.string().optional(),
      })
    )
    .mutation(async ({ ctx, input }) => {
      if (!isFeatureEnabled("ENABLE_JURY_GROUPS")) {
        throw new TRPCError({ code: "FORBIDDEN", message: "Feature not enabled" });
      }

      return ctx.prisma.juryGroupMember.create({
        data: input,
      });
    }),

  // Remove member from jury group
  removeMember: adminProcedure
    .input(z.object({ id: z.string() }))
    .mutation(async ({ ctx, input }) => {
      if (!isFeatureEnabled("ENABLE_JURY_GROUPS")) {
        throw new TRPCError({ code: "FORBIDDEN", message: "Feature not enabled" });
      }

      return ctx.prisma.juryGroupMember.delete({
        where: { id: input.id },
      });
    }),

  // List jury groups for competition
  listByCompetition: protectedProcedure
    .input(z.object({ competitionId: z.string() }))
    .query(async ({ ctx, input }) => {
      if (!isFeatureEnabled("ENABLE_JURY_GROUPS")) {
        return [];
      }

      return ctx.prisma.juryGroup.findMany({
        where: { competitionId: input.competitionId },
        include: {
          members: { include: { user: true } },
          rounds: true,
        },
        orderBy: { sortOrder: "asc" },
      });
    }),

  // Get jury group by ID
  getById: protectedProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ ctx, input }) => {
      if (!isFeatureEnabled("ENABLE_JURY_GROUPS")) {
        throw new TRPCError({ code: "NOT_FOUND" });
      }

      const juryGroup = await ctx.prisma.juryGroup.findUnique({
        where: { id: input.id },
        include: {
          competition: true,
          members: { include: { user: true } },
          rounds: true,
          assignments: { include: { project: true, user: true } },
        },
      });

      if (!juryGroup) {
        throw new TRPCError({ code: "NOT_FOUND", message: "Jury group not found" });
      }

      return juryGroup;
    }),

  // Update jury group
  update: adminProcedure
    .input(
      z.object({
        id: z.string(),
        name: z.string().optional(),
        description: z.string().optional(),
        defaultMaxAssignments: z.number().optional(),
        defaultCapMode: z.nativeEnum(CapMode).optional(),
        softCapBuffer: z.number().optional(),
        categoryQuotasEnabled: z.boolean().optional(),
        defaultCategoryQuotas: z.any().optional(),
        allowJurorCapAdjustment: z.boolean().optional(),
        allowJurorRatioAdjustment: z.boolean().optional(),
      })
    )
    .mutation(async ({ ctx, input }) => {
      if (!isFeatureEnabled("ENABLE_JURY_GROUPS")) {
        throw new TRPCError({ code: "FORBIDDEN", message: "Feature not enabled" });
      }

      const { id, ...data } = input;

      return ctx.prisma.juryGroup.update({
        where: { id },
        data,
      });
    }),
});

1.5 SubmissionWindow CRUD Router

File to Create:

  • src/server/routers/submission-window.ts

Similar structure to jury-group.ts, with create/update/delete/list/getById for SubmissionWindow and SubmissionFileRequirement.

1.6 Round Engine (Simplified State Machine)

File to Create:

  • src/server/services/round-engine.ts
import { prisma } from "@/lib/prisma";
import { ProjectRoundStateValue, RoundStatus } from "@prisma/client";
import { TRPCError } from "@trpc/server";

export class RoundEngine {
  // Transition project to a round
  async transitionProjectToRound(
    projectId: string,
    roundId: string,
    state: ProjectRoundStateValue = "PENDING"
  ) {
    const existingState = await prisma.projectRoundState.findUnique({
      where: { projectId_roundId: { projectId, roundId } },
    });

    if (existingState) {
      // Update existing
      return prisma.projectRoundState.update({
        where: { id: existingState.id },
        data: { state },
      });
    } else {
      // Create new
      return prisma.projectRoundState.create({
        data: { projectId, roundId, state },
      });
    }
  }

  // Advance project to PASSED (ready for next round)
  async markProjectPassed(projectId: string, roundId: string) {
    return this.transitionProjectToRound(projectId, roundId, "PASSED");
  }

  // Reject project
  async markProjectRejected(projectId: string, roundId: string) {
    return this.transitionProjectToRound(projectId, roundId, "REJECTED");
  }

  // Get all projects in a round with specific state
  async getProjectsInRoundByState(roundId: string, state?: ProjectRoundStateValue) {
    return prisma.projectRoundState.findMany({
      where: {
        roundId,
        ...(state && { state }),
      },
      include: { project: true },
    });
  }

  // Advance all PASSED projects from source round to target round
  async advancePassedProjects(sourceRoundId: string, targetRoundId: string) {
    const passedProjects = await this.getProjectsInRoundByState(sourceRoundId, "PASSED");

    const results = await Promise.all(
      passedProjects.map((prs) =>
        this.transitionProjectToRound(prs.projectId, targetRoundId, "PENDING")
      )
    );

    return results;
  }

  // Open round (set status to ROUND_ACTIVE)
  async openRound(roundId: string) {
    return prisma.round.update({
      where: { id: roundId },
      data: { status: "ROUND_ACTIVE" },
    });
  }

  // Close round
  async closeRound(roundId: string) {
    return prisma.round.update({
      where: { id: roundId },
      data: { status: "ROUND_CLOSED" },
    });
  }
}

export const roundEngine = new RoundEngine();

1.7 Register New Routers in App Router

File to Modify:

  • src/server/routers/_app.ts
import { competitionRouter } from "./competition";
import { roundRouter } from "./round";
import { juryGroupRouter } from "./jury-group";
import { submissionWindowRouter } from "./submission-window";

export const appRouter = router({
  // ... existing routers
  competition: competitionRouter,
  round: roundRouter,
  juryGroup: juryGroupRouter,
  submissionWindow: submissionWindowRouter,
});

1.8 Unit Tests for Phase 1

Files to Create:

  • tests/unit/competition.test.ts
  • tests/unit/round.test.ts
  • tests/unit/jury-group.test.ts
  • tests/unit/submission-window.test.ts
  • tests/unit/round-engine.test.ts

Example test structure:

// tests/unit/competition.test.ts
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { prisma } from "../setup";
import { createTestUser, createTestProgram, createTestCompetition, cleanupTestDataRedesign } from "../helpers-redesign";
import { createCaller } from "../setup";
import { competitionRouter } from "@/server/routers/competition";

describe("Competition Router", () => {
  let adminUser: any;
  let program: any;

  beforeAll(async () => {
    adminUser = await createTestUser({ role: "PROGRAM_ADMIN" });
    program = await createTestProgram();
  });

  afterAll(async () => {
    await cleanupTestDataRedesign();
  });

  it("should create a competition", async () => {
    const caller = createCaller(competitionRouter, adminUser);

    const competition = await caller.create({
      programId: program.id,
      name: "Test Competition 2026",
      slug: "test-comp-2026",
    });

    expect(competition).toBeDefined();
    expect(competition.name).toBe("Test Competition 2026");
    expect(competition.status).toBe("DRAFT");
  });

  it("should list competitions by program", async () => {
    const caller = createCaller(competitionRouter, adminUser);

    const competitions = await caller.listByProgram({ programId: program.id });

    expect(competitions.length).toBeGreaterThan(0);
  });

  // ... more tests
});

Acceptance Criteria

  • Prisma migration applied successfully
  • All new tables exist in database
  • Competition CRUD works (create, read, update, delete)
  • Round CRUD works
  • JuryGroup CRUD works (create group, add/remove members)
  • SubmissionWindow CRUD works
  • ProjectRoundState can be created and queried
  • Round engine can transition projects between states
  • Round engine can advance projects to next round
  • All unit tests pass (100% coverage for new routers)
  • TypeScript compiles without errors
  • No regressions in existing system (old pipelines still work)

Deliverables

  1. Prisma schema with all new models
  2. Applied migration
  3. Competition router with full CRUD
  4. Round router with full CRUD
  5. JuryGroup router with full CRUD
  6. SubmissionWindow router with full CRUD
  7. Round engine service with state transitions
  8. Complete unit test coverage
  9. Documentation updates

Risks & Mitigations

Risk Impact Mitigation
Migration fails on existing data High Additive-only migration, no destructive changes
Performance issues with new indexes Medium Load test with seed data, optimize indexes
Type mismatches Prisma vs TypeScript Low Regenerate client after each schema change

Phase 2: Backend Orchestration

Weeks 3-5

Goals

  1. Enhanced assignment algorithm with jury groups, caps, quotas
  2. Filtering service updates (stageId → roundId)
  3. Submission round manager (window locking, deadline enforcement)
  4. Mentor workspace service (file upload, comments, promotion)
  5. Winner confirmation service (proposals, approvals, freeze)
  6. Live control enhancements (category windows)

Dependencies

  • Phase 1 complete

Tasks

2.1 Enhanced Assignment Service

File to Modify:

  • src/server/services/stage-assignment.ts → rename to src/server/services/round-assignment.ts

New Features:

  1. Jury group awareness — Generate assignments respecting juryGroupId
  2. Hard/soft caps — Enforce per-juror max assignments with buffer
  3. Category quotas — Respect min/max quotas per category
  4. Juror preferences — Factor in preferredStartupRatio
// src/server/services/round-assignment.ts
import { prisma } from "@/lib/prisma";
import { AssignmentMethod, CapMode } from "@prisma/client";

interface AssignmentConfig {
  roundId: string;
  juryGroupId: string;
  requiredReviewsPerProject: number;
}

export class RoundAssignmentService {
  async generateAssignments(config: AssignmentConfig) {
    const { roundId, juryGroupId, requiredReviewsPerProject } = config;

    // 1. Get jury group with members
    const juryGroup = await prisma.juryGroup.findUnique({
      where: { id: juryGroupId },
      include: { members: { include: { user: true } } },
    });

    if (!juryGroup) {
      throw new Error("Jury group not found");
    }

    // 2. Get projects in round (PENDING or IN_PROGRESS)
    const projectRoundStates = await prisma.projectRoundState.findMany({
      where: {
        roundId,
        state: { in: ["PENDING", "IN_PROGRESS"] },
      },
      include: { project: true },
    });

    const projects = projectRoundStates.map((prs) => prs.project);

    // 3. Get existing assignments
    const existingAssignments = await prisma.assignment.findMany({
      where: { roundId, juryGroupId },
    });

    // 4. Calculate caps per juror
    const jurorCaps = juryGroup.members.map((member) => {
      const maxAssignments =
        member.maxAssignmentsOverride ?? juryGroup.defaultMaxAssignments;
      const capMode = member.capModeOverride ?? juryGroup.defaultCapMode;

      return {
        userId: member.userId,
        maxAssignments,
        capMode,
        softCapBuffer: juryGroup.softCapBuffer,
        categoryQuotas: member.categoryQuotasOverride ?? juryGroup.defaultCategoryQuotas,
        preferredStartupRatio: member.preferredStartupRatio,
        currentCount: existingAssignments.filter((a) => a.userId === member.userId).length,
      };
    });

    // 5. Generate assignments using algorithm (AI or round-robin)
    const newAssignments = [];

    for (const project of projects) {
      const projectAssignments = existingAssignments.filter(
        (a) => a.projectId === project.id
      );

      // How many more assignments needed?
      const needed = requiredReviewsPerProject - projectAssignments.length;

      if (needed <= 0) continue;

      // Find available jurors (respecting caps and quotas)
      const availableJurors = jurorCaps.filter((juror) => {
        // Check hard cap
        if (juror.capMode === "HARD" && juror.currentCount >= juror.maxAssignments) {
          return false;
        }

        // Check soft cap (can exceed by buffer)
        if (juror.capMode === "SOFT") {
          const softMax = juror.maxAssignments + juror.softCapBuffer;
          if (juror.currentCount >= softMax) {
            return false;
          }
        }

        // Check category quotas
        if (juror.categoryQuotas && project.competitionCategory) {
          const quota = juror.categoryQuotas[project.competitionCategory];
          if (quota) {
            const categoryCount = existingAssignments.filter(
              (a) =>
                a.userId === juror.userId &&
                a.project?.competitionCategory === project.competitionCategory
            ).length;

            if (categoryCount >= quota.max) {
              return false;
            }
          }
        }

        // Check COI (not implemented here, would query ConflictOfInterest)

        return true;
      });

      // Select jurors (round-robin or AI-based)
      const selectedJurors = availableJurors.slice(0, needed);

      for (const juror of selectedJurors) {
        newAssignments.push({
          userId: juror.userId,
          projectId: project.id,
          roundId,
          juryGroupId,
          method: "ALGORITHM" as AssignmentMethod,
          isRequired: true,
        });

        juror.currentCount++;
      }
    }

    // 6. Insert assignments
    const created = await prisma.assignment.createMany({
      data: newAssignments,
    });

    return { created: created.count, assignments: newAssignments };
  }

  // Check if juror can take more assignments
  async canJurorTakeAssignment(userId: string, roundId: string, projectId: string) {
    const assignment = await prisma.assignment.findFirst({
      where: { userId, roundId },
      include: { juryGroup: { include: { members: true } } },
    });

    if (!assignment) return false;

    const juryGroup = assignment.juryGroup;
    if (!juryGroup) return false;

    const member = juryGroup.members.find((m) => m.userId === userId);
    if (!member) return false;

    const maxAssignments = member.maxAssignmentsOverride ?? juryGroup.defaultMaxAssignments;
    const capMode = member.capModeOverride ?? juryGroup.defaultCapMode;

    const currentCount = await prisma.assignment.count({
      where: { userId, roundId, juryGroupId: juryGroup.id },
    });

    if (capMode === "HARD") {
      return currentCount < maxAssignments;
    } else if (capMode === "SOFT") {
      const softMax = maxAssignments + juryGroup.softCapBuffer;
      return currentCount < softMax;
    }

    return true; // NONE
  }
}

export const roundAssignmentService = new RoundAssignmentService();

2.2 Filtering Service Updates

File to Modify:

  • src/server/services/stage-filtering.ts → keep name, just update references

Changes:

  1. Replace stageId with roundId in all queries
  2. Update FilteringJob, FilteringResult, FilteringRule to use roundId

This is mostly a search-and-replace task with careful testing.

2.3 Submission Round Manager Service

File to Create:

  • src/server/services/submission-round-manager.ts
import { prisma } from "@/lib/prisma";
import { DeadlinePolicy } from "@prisma/client";

export class SubmissionRoundManager {
  // Open a submission window
  async openWindow(submissionWindowId: string) {
    const window = await prisma.submissionWindow.update({
      where: { id: submissionWindowId },
      data: { windowOpenAt: new Date() },
    });

    // Lock all previous windows (if configured)
    const competition = await prisma.competition.findUnique({
      where: { id: window.competitionId },
      include: { submissionWindows: { orderBy: { roundNumber: "asc" } } },
    });

    if (competition) {
      const previousWindows = competition.submissionWindows.filter(
        (w) => w.roundNumber < window.roundNumber
      );

      for (const prevWindow of previousWindows) {
        if (prevWindow.lockOnClose) {
          await prisma.submissionWindow.update({
            where: { id: prevWindow.id },
            data: { windowCloseAt: new Date() },
          });
        }
      }
    }

    return window;
  }

  // Close a submission window
  async closeWindow(submissionWindowId: string) {
    return prisma.submissionWindow.update({
      where: { id: submissionWindowId },
      data: { windowCloseAt: new Date() },
    });
  }

  // Check if submission is allowed (respects deadline policy)
  async canSubmit(projectId: string, submissionWindowId: string) {
    const window = await prisma.submissionWindow.findUnique({
      where: { id: submissionWindowId },
    });

    if (!window) return { allowed: false, reason: "Window not found" };

    const now = new Date();

    // Before window opens
    if (window.windowOpenAt && now < window.windowOpenAt) {
      return { allowed: false, reason: "Window not yet open" };
    }

    // After window closes
    if (window.windowCloseAt && now > window.windowCloseAt) {
      if (window.deadlinePolicy === "HARD") {
        return { allowed: false, reason: "Deadline passed (hard cutoff)" };
      } else if (window.deadlinePolicy === "GRACE" && window.graceHours) {
        const graceCutoff = new Date(
          window.windowCloseAt.getTime() + window.graceHours * 60 * 60 * 1000
        );
        if (now > graceCutoff) {
          return { allowed: false, reason: "Grace period expired" };
        } else {
          return { allowed: true, isLate: true };
        }
      } else if (window.deadlinePolicy === "FLAG") {
        return { allowed: true, isLate: true };
      }
    }

    return { allowed: true, isLate: false };
  }

  // Upload file for submission window
  async uploadFile(
    projectId: string,
    submissionWindowId: string,
    requirementId: string,
    fileData: any
  ) {
    const canSubmitResult = await this.canSubmit(projectId, submissionWindowId);

    if (!canSubmitResult.allowed) {
      throw new Error(canSubmitResult.reason);
    }

    return prisma.projectFile.create({
      data: {
        projectId,
        submissionWindowId,
        requirementId,
        isLate: canSubmitResult.isLate || false,
        ...fileData,
      },
    });
  }
}

export const submissionRoundManager = new SubmissionRoundManager();

2.4 Mentor Workspace Service

File to Create:

  • src/server/services/mentor-workspace.ts
import { prisma } from "@/lib/prisma";

export class MentorWorkspaceService {
  // Activate workspace for a mentoring round
  async activateWorkspace(mentorAssignmentId: string, openAt: Date, closeAt: Date) {
    return prisma.mentorAssignment.update({
      where: { id: mentorAssignmentId },
      data: {
        workspaceEnabled: true,
        workspaceOpenAt: openAt,
        workspaceCloseAt: closeAt,
      },
    });
  }

  // Upload file to workspace
  async uploadFile(
    mentorAssignmentId: string,
    uploadedByUserId: string,
    fileData: {
      fileName: string;
      mimeType: string;
      size: number;
      bucket: string;
      objectKey: string;
      description?: string;
    }
  ) {
    return prisma.mentorFile.create({
      data: {
        mentorAssignmentId,
        uploadedByUserId,
        ...fileData,
      },
    });
  }

  // Add comment to file
  async addComment(
    mentorFileId: string,
    authorId: string,
    content: string,
    parentCommentId?: string
  ) {
    return prisma.mentorFileComment.create({
      data: {
        mentorFileId,
        authorId,
        content,
        parentCommentId,
      },
    });
  }

  // Promote file to official submission
  async promoteFileToSubmission(
    mentorFileId: string,
    promotedByUserId: string,
    targetSubmissionWindowId: string,
    targetRequirementId: string
  ) {
    const mentorFile = await prisma.mentorFile.findUnique({
      where: { id: mentorFileId },
      include: { mentorAssignment: { include: { project: true } } },
    });

    if (!mentorFile) throw new Error("File not found");
    if (mentorFile.isPromoted) throw new Error("File already promoted");

    // Create ProjectFile
    const projectFile = await prisma.projectFile.create({
      data: {
        projectId: mentorFile.mentorAssignment.projectId,
        submissionWindowId: targetSubmissionWindowId,
        requirementId: targetRequirementId,
        fileType: "APPLICATION_DOC",
        fileName: mentorFile.fileName,
        mimeType: mentorFile.mimeType,
        size: mentorFile.size,
        bucket: mentorFile.bucket,
        objectKey: mentorFile.objectKey,
      },
    });

    // Mark mentor file as promoted
    await prisma.mentorFile.update({
      where: { id: mentorFileId },
      data: {
        isPromoted: true,
        promotedToFileId: projectFile.id,
        promotedAt: new Date(),
        promotedByUserId,
      },
    });

    return projectFile;
  }

  // Get workspace files for mentor assignment
  async getWorkspaceFiles(mentorAssignmentId: string) {
    return prisma.mentorFile.findMany({
      where: { mentorAssignmentId },
      include: {
        uploadedBy: true,
        comments: { include: { author: true } },
        promotedFile: true,
      },
      orderBy: { createdAt: "desc" },
    });
  }
}

export const mentorWorkspaceService = new MentorWorkspaceService();

2.5 Winner Confirmation Service

File to Create:

  • src/server/services/winner-confirmation.ts
import { prisma } from "@/lib/prisma";
import { CompetitionCategory, WinnerProposalStatus } from "@prisma/client";

export class WinnerConfirmationService {
  // Create winner proposal
  async createProposal(
    competitionId: string,
    category: CompetitionCategory,
    rankedProjectIds: string[],
    sourceRoundId: string,
    selectionBasis: any,
    proposedById: string
  ) {
    return prisma.winnerProposal.create({
      data: {
        competitionId,
        category,
        rankedProjectIds,
        sourceRoundId,
        selectionBasis,
        proposedById,
        status: "PENDING",
      },
    });
  }

  // Request approvals from jury members
  async requestApprovals(winnerProposalId: string, juryMemberUserIds: string[]) {
    const approvals = juryMemberUserIds.map((userId) => ({
      winnerProposalId,
      userId,
      role: "JURY_MEMBER" as const,
    }));

    return prisma.winnerApproval.createMany({
      data: approvals,
    });
  }

  // Submit approval
  async submitApproval(
    winnerProposalId: string,
    userId: string,
    approved: boolean,
    comments?: string
  ) {
    const approval = await prisma.winnerApproval.update({
      where: {
        winnerProposalId_userId: { winnerProposalId, userId },
      },
      data: {
        approved,
        comments,
        respondedAt: new Date(),
      },
    });

    // Check if all approvals are in
    await this.checkAndUpdateProposalStatus(winnerProposalId);

    return approval;
  }

  // Check approval status and update proposal
  async checkAndUpdateProposalStatus(winnerProposalId: string) {
    const proposal = await prisma.winnerProposal.findUnique({
      where: { id: winnerProposalId },
      include: { approvals: true },
    });

    if (!proposal) return;

    const allResponded = proposal.approvals.every((a) => a.approved !== null);

    if (allResponded) {
      const allApproved = proposal.approvals.every((a) => a.approved === true);
      const anyRejected = proposal.approvals.some((a) => a.approved === false);

      if (allApproved) {
        await prisma.winnerProposal.update({
          where: { id: winnerProposalId },
          data: { status: "APPROVED" },
        });
      } else if (anyRejected) {
        await prisma.winnerProposal.update({
          where: { id: winnerProposalId },
          data: { status: "REJECTED" },
        });
      }
    }
  }

  // Admin override
  async applyOverride(
    winnerProposalId: string,
    overrideMode: string,
    overrideReason: string,
    overrideById: string
  ) {
    return prisma.winnerProposal.update({
      where: { id: winnerProposalId },
      data: {
        status: "OVERRIDDEN",
        overrideUsed: true,
        overrideMode,
        overrideReason,
        overrideById,
      },
    });
  }

  // Freeze results (lock as official)
  async freezeResults(winnerProposalId: string, frozenById: string) {
    return prisma.winnerProposal.update({
      where: { id: winnerProposalId },
      data: {
        status: "FROZEN",
        frozenAt: new Date(),
        frozenById,
      },
    });
  }
}

export const winnerConfirmationService = new WinnerConfirmationService();

2.6 Integration Tests for Phase 2

Files to Create:

  • tests/integration/assignment-service.test.ts
  • tests/integration/submission-round-manager.test.ts
  • tests/integration/mentor-workspace.test.ts
  • tests/integration/winner-confirmation.test.ts

Acceptance Criteria

  • Assignment service generates assignments with jury group caps/quotas
  • Filtering service works with roundId instead of stageId
  • Submission round manager enforces deadline policies correctly
  • Mentor workspace service allows file upload, comments, and promotion
  • Winner confirmation service creates proposals and tracks approvals
  • All integration tests pass
  • No performance regressions (load test with 1000 projects, 50 jurors)

Deliverables

  1. Enhanced assignment service
  2. Updated filtering service
  3. Submission round manager service
  4. Mentor workspace service
  5. Winner confirmation service
  6. Complete integration test coverage
  7. Performance test report

Risks & Mitigations

Risk Impact Mitigation
Assignment algorithm too slow High Optimize queries, add caching
Deadline logic has edge cases Medium Comprehensive test matrix
File promotion conflicts Medium Transaction isolation, unique constraints

Phase 3: Admin Control Plane

Weeks 4-6

Goals

  1. Competition setup wizard (replaces pipeline wizard)
  2. Round management UI
  3. Jury group management UI
  4. Submission window configuration UI
  5. Stage manager enhancements (live control)

Dependencies

  • Phase 1 complete
  • Partial Phase 2 (services can be stubbed for UI development)

Tasks

3.1 Competition Setup Wizard

Files to Create:

  • src/app/(admin)/competitions/new/page.tsx
  • src/components/admin/competition-wizard/WizardSteps.tsx
  • src/components/admin/competition-wizard/Step1-BasicInfo.tsx
  • src/components/admin/competition-wizard/Step2-Rounds.tsx
  • src/components/admin/competition-wizard/Step3-JuryGroups.tsx
  • src/components/admin/competition-wizard/Step4-SubmissionWindows.tsx
  • src/components/admin/competition-wizard/Step5-SpecialAwards.tsx
  • src/components/admin/competition-wizard/Step6-Review.tsx

Wizard Flow:

  1. Basic Info — Name, slug, category mode, finalist counts
  2. Rounds — Add rounds with type, config, order
  3. Jury Groups — Create groups, add members, set caps
  4. Submission Windows — Define doc requirements per round
  5. Special Awards — Configure awards (if any)
  6. Review & Create — Summary and save

Similar structure to existing pipeline wizard, but with clearer types.

3.2 Round Management UI

Files to Create:

  • src/app/(admin)/competitions/[id]/rounds/page.tsx
  • src/components/admin/rounds/RoundList.tsx
  • src/components/admin/rounds/RoundCard.tsx
  • src/components/admin/rounds/RoundEditor.tsx
  • src/components/admin/rounds/config-editors/IntakeConfigEditor.tsx
  • src/components/admin/rounds/config-editors/FilteringConfigEditor.tsx
  • src/components/admin/rounds/config-editors/EvaluationConfigEditor.tsx
  • ... (one per round type)

Features:

  • Drag-and-drop reordering
  • Round status badges
  • Click to edit round config
  • Link rounds to jury groups and submission windows
  • Preview advancement rules

3.3 Jury Group Management UI

Files to Create:

  • src/app/(admin)/competitions/[id]/jury-groups/page.tsx
  • src/components/admin/jury-groups/JuryGroupList.tsx
  • src/components/admin/jury-groups/JuryGroupCard.tsx
  • src/components/admin/jury-groups/MemberManager.tsx
  • src/components/admin/jury-groups/CapQuotaEditor.tsx

Features:

  • Add/remove jury members
  • Set per-juror overrides (caps, quotas)
  • View assignment distribution
  • See which rounds this jury is assigned to

3.4 Submission Window Configuration UI

Files to Create:

  • src/app/(admin)/competitions/[id]/submission-windows/page.tsx
  • src/components/admin/submission-windows/WindowList.tsx
  • src/components/admin/submission-windows/WindowEditor.tsx
  • src/components/admin/submission-windows/RequirementManager.tsx

Features:

  • Add/edit submission windows
  • Configure file requirements (type, size, required/optional)
  • Set deadline policies (hard/flag/grace)
  • Configure visibility (which jury rounds see which windows)

3.5 Enhanced Stage Manager (Live Control)

Files to Modify:

  • src/app/(admin)/stage-manager/[stageId]/page.tsx → keep, but add support for roundId
  • Add support for category-specific windows in live finals

Acceptance Criteria

  • Admin can create full competition through wizard
  • Admin can edit rounds and reorder them
  • Admin can manage jury groups and members
  • Admin can configure submission windows
  • Stage manager works with rounds (backward compatible with stages)
  • All admin UI flows tested manually
  • Responsive design works on tablet/desktop

Deliverables

  1. Competition wizard (6-step flow)
  2. Round management UI
  3. Jury group management UI
  4. Submission window UI
  5. Enhanced stage manager
  6. UI/UX documentation

Risks & Mitigations

Risk Impact Mitigation
Wizard too complex Medium User testing, simplify steps
Config editors error-prone High Zod validation + clear error messages
Performance with large jury groups Low Pagination, virtualized lists

Phase 4: Participant Journeys

Weeks 5-7

Goals

  1. Applicant UI: multi-round submissions, mentoring workspace
  2. Jury UI: multi-jury dashboard, cross-round document visibility
  3. Mentor UI: dedicated dashboard and workspace
  4. Audience voting UI (enhanced)

Dependencies

  • Phase 2 complete (backend services)
  • Phase 3 complete (admin can create competitions)

Tasks

4.1 Applicant Multi-Round Submission UI

Files to Modify:

  • src/app/(applicant)/dashboard/page.tsx — Add multi-round status
  • src/app/(applicant)/submit/page.tsx — Support multiple submission windows

Files to Create:

  • src/components/applicant/SubmissionTimeline.tsx — Shows all windows
  • src/components/applicant/WindowUploader.tsx — Upload per window
  • src/components/applicant/MentoringWorkspace.tsx — Mentor file exchange

Features:

  • See all submission windows (upcoming, active, closed)
  • Upload files to active window
  • View previous round submissions (read-only when locked)
  • Access mentoring workspace when available

4.2 Applicant Mentoring Workspace

Files to Create:

  • src/app/(applicant)/mentoring/page.tsx
  • src/components/applicant/workspace/FileList.tsx
  • src/components/applicant/workspace/FileUpload.tsx
  • src/components/applicant/workspace/FileComments.tsx

Features:

  • Upload files for mentor review
  • See mentor-uploaded files
  • Comment on files
  • See which files were promoted to official submission

4.3 Jury Multi-Round Dashboard

Files to Modify:

  • src/app/(jury)/dashboard/page.tsx — Show assignments from multiple rounds

Files to Create:

  • src/components/jury/MultiRoundAssignments.tsx
  • src/components/jury/RoundTabs.tsx
  • src/components/jury/CrossRoundDocViewer.tsx

Features:

  • See assignments across all jury groups user belongs to
  • Filter by round
  • View documents from multiple submission windows (as configured per round)
  • See evaluation history (from previous rounds)

4.4 Mentor Dashboard and Workspace

Files to Create:

  • src/app/(mentor)/dashboard/page.tsx
  • src/app/(mentor)/workspace/[projectId]/page.tsx
  • src/components/mentor/MentorAssignmentList.tsx
  • src/components/mentor/WorkspaceFileManager.tsx
  • src/components/mentor/PromoteFileDialog.tsx

Features:

  • See all assigned teams
  • Upload files to workspace
  • Comment on team files
  • Promote files to official submission

4.5 Enhanced Audience Voting UI

Files to Modify:

  • src/app/(public)/live-finals/[slug]/page.tsx — Add category-specific voting

Features:

  • Vote per category or overall (based on round config)
  • Favorites mode (pick top N)
  • Real-time vote count updates

Acceptance Criteria

  • Applicant can upload to multiple submission windows
  • Applicant can access mentoring workspace
  • Jury can see assignments from multiple rounds
  • Jury can view documents from multiple windows
  • Mentor can upload files and promote them
  • Audience voting supports all modes (per-project, per-category, favorites)
  • All participant UIs tested manually
  • Mobile responsive

Deliverables

  1. Applicant multi-round submission UI
  2. Applicant mentoring workspace UI
  3. Jury multi-round dashboard
  4. Mentor dashboard and workspace
  5. Enhanced audience voting UI
  6. End-to-end user journey documentation

Risks & Mitigations

Risk Impact Mitigation
Confusion with multiple windows High Clear timeline UI, status badges
File upload errors Medium Robust error handling, retry logic
Mentor workspace access control High Strict auth checks, audit logging

Phase 5: Special Awards & Governance

Weeks 6-7

Goals

  1. Enhanced special awards (two modes: STAY_IN_MAIN, SEPARATE_POOL)
  2. Award jury integration
  3. Winner confirmation UI
  4. PDF export of results

Dependencies

  • Phase 2 complete (backend services)
  • Phase 4 complete (participant UIs for award voting)

Tasks

5.1 Enhanced Special Awards Backend

File to Modify:

  • src/server/routers/special-award.ts — Add new fields (competitionId, evaluationRoundId, juryGroupId, eligibilityMode)

New Logic:

  • STAY_IN_MAIN: Projects remain in main flow, flagged as award-eligible
  • SEPARATE_POOL: Projects pulled out of main flow (set ProjectRoundState to WITHDRAWN from main, create separate state for award)

5.2 Award Jury Management UI

Files to Create:

  • src/app/(admin)/competitions/[id]/awards/page.tsx
  • src/components/admin/awards/AwardList.tsx
  • src/components/admin/awards/AwardEditor.tsx
  • src/components/admin/awards/AwardJuryManager.tsx

Features:

  • Create award with eligibility mode
  • Link to jury group (shared or dedicated)
  • Set voting window
  • Configure scoring mode (pick winner, ranked, scored)

5.3 Winner Confirmation UI

Files to Create:

  • src/app/(admin)/competitions/[id]/confirm-winners/page.tsx
  • src/components/admin/winner-confirmation/ProposalList.tsx
  • src/components/admin/winner-confirmation/ProposalEditor.tsx
  • src/components/admin/winner-confirmation/ApprovalStatus.tsx
  • src/components/admin/winner-confirmation/OverrideDialog.tsx

Features:

  • Create winner proposal (ranked list)
  • Request approvals from jury
  • Track approval status
  • Admin override options (force majority, admin decision)
  • Freeze results

5.4 Jury Approval UI

Files to Create:

  • src/app/(jury)/confirm-winners/page.tsx
  • src/components/jury/WinnerApprovalCard.tsx

Features:

  • View proposed rankings
  • Approve/reject with comments
  • See selection basis (scores, votes)

5.5 PDF Export of Results

File to Create:

  • src/server/services/pdf-export.ts

Use @react-pdf/renderer or similar to generate PDF with:

  • Competition name and date
  • Final rankings per category
  • Jury signatures (names + approval timestamps)
  • Special award winners

Acceptance Criteria

  • Admin can create awards with both modes
  • STAY_IN_MAIN mode keeps projects in main flow
  • SEPARATE_POOL mode removes projects from main
  • Award jury can vote on eligible projects
  • Admin can propose winners and request approvals
  • Jury can approve/reject proposals
  • Admin can override with audit trail
  • PDF export generates correctly
  • All award workflows tested end-to-end

Deliverables

  1. Enhanced special award backend
  2. Award management UI
  3. Winner confirmation UI (admin + jury)
  4. PDF export service
  5. Award flow documentation

Risks & Mitigations

Risk Impact Mitigation
SEPARATE_POOL logic complex High Extensive testing, clear state transitions
Approval deadlock (no consensus) Medium Admin override path well-documented
PDF generation fails Low Fallback to HTML view

Phase 6: Platform-Wide Refit

Weeks 7-8

Goals

  1. Remove all Pipeline/Track/Stage references from codebase
  2. Drop old tables and enums (Phase 4 of migration)
  3. Update all imports and types
  4. Remove feature flags
  5. Clean build with no legacy code

Dependencies

  • All previous phases complete
  • Full competition created and tested with new system

Tasks

6.1 Code Audit and Replacement

Search and Replace:

  • PipelineCompetition (400+ occurrences expected)
  • StageRound (600+ occurrences expected)
  • Track → (remove, refactor)
  • stageIdroundId
  • trackId → (remove)

Files to Audit:

  • All routers (src/server/routers/*.ts)
  • All services (src/server/services/*.ts)
  • All components (src/components/**/*.tsx)
  • All pages (src/app/**/*.tsx)
  • All types (src/types/*.ts)

Tools:

# Search for legacy references
npx ripgrep "Pipeline" --type ts --type tsx
npx ripgrep "Track(?!ing)" --type ts --type tsx
npx ripgrep "Stage(?!Manager)" --type ts --type tsx
npx ripgrep "stageId" --type ts --type tsx
npx ripgrep "trackId" --type ts --type tsx

# After replacement, verify no references remain
npx ripgrep "Pipeline|Track(?!ing)|Stage(?!Manager)|stageId|trackId" --type ts --type tsx

6.2 Prisma Schema Cleanup (Phase 4 — Destructive)

File to Modify:

  • prisma/schema.prisma

Tasks:

# 6.2.1 Create migration to drop old tables
npx prisma migrate dev --name drop_legacy_pipeline_track_stage_models --create-only

# 6.2.2 Review generated SQL
# Verify:
# - DROP TABLE Pipeline
# - DROP TABLE Track
# - DROP TABLE Stage
# - DROP TABLE StageTransition
# - DROP TABLE ProjectStageState
# - DROP TYPE TrackKind
# - DROP TYPE RoutingMode
# - DROP TYPE DecisionMode (if not used by SpecialAward)
# - Remove legacy columns: Assignment.stageId, Project.roundId (old), etc.

# 6.2.3 Apply migration
npx prisma migrate dev

# 6.2.4 Regenerate client
npx prisma generate

6.3 Remove Feature Flags

File to Modify:

  • src/lib/feature-flags.ts — Delete file entirely
  • Search and remove all isFeatureEnabled() checks

6.4 Update Documentation

Files to Modify:

  • CLAUDE.md — Remove pipeline references, add competition/round context
  • docs/architecture.md — Update diagrams
  • README.md — Update feature list

6.5 Full Regression Test Suite

Run all tests:

npx vitest run
npm run typecheck
npm run lint
npm run build

Manual testing:

  • Create competition through wizard
  • Configure all round types
  • Run full competition end-to-end (intake → filtering → evaluation → submission → mentoring → live finals → confirmation)
  • Verify data integrity
  • Check all participant journeys

Acceptance Criteria

  • No references to Pipeline, Track, Stage in codebase (except comments/docs)
  • Old tables dropped from schema
  • All tests pass
  • TypeScript compiles without errors
  • Production build succeeds
  • Full competition tested end-to-end
  • Documentation updated
  • Feature flags removed

Deliverables

  1. Clean codebase (no legacy code)
  2. Updated Prisma schema (old tables dropped)
  3. Passing test suite
  4. Updated documentation
  5. Migration runbook

Risks & Mitigations

Risk Impact Mitigation
Missed legacy references cause runtime errors High Comprehensive search, TypeScript strict mode
Data loss during migration Critical Backup database before Phase 6, test on staging first
Tests fail after refactor High Fix tests incrementally, don't skip

Phase 7: Validation & Release

Weeks 8-9

Goals

  1. Full integration testing
  2. Performance testing
  3. UAT with stakeholders
  4. Data migration on staging
  5. Production deployment

Dependencies

  • All previous phases complete

Tasks

7.1 Integration Testing

Test Scenarios:

  1. Create competition with all round types
  2. Invite and onboard jury members across multiple groups
  3. Applicants submit to multiple windows
  4. Jury evaluates across multiple rounds
  5. Mentor workspace file exchange
  6. Live finals with audience voting
  7. Winner confirmation with approvals
  8. Special awards (both modes)

Files to Create:

  • tests/integration/full-competition-flow.test.ts (comprehensive E2E test)

7.2 Performance Testing

Benchmarks:

  • 1000 projects, 50 jurors, 3 rounds
  • Assignment generation time < 5 seconds
  • Filtering batch time < 30 seconds
  • Page load times < 2 seconds (P95)
  • Database query performance (no N+1 queries)

Tools:

  • Vitest benchmarks
  • Prisma query logging
  • Chrome DevTools profiling

7.3 UAT with Stakeholders

Participants:

  • Program admin
  • Jury members (2-3)
  • Applicant
  • Mentor

Scenarios:

  • Admin: Create competition, configure rounds
  • Applicant: Submit application, upload to multiple windows
  • Jury: Evaluate projects, see cross-round docs
  • Mentor: Upload files, promote to submission

Feedback Collection:

  • Screen recordings
  • Bug reports
  • UX feedback

7.4 Data Migration on Staging

Tasks:

  1. Backup production database
  2. Restore to staging
  3. Run migration scripts (Phases 1-4)
  4. Verify data integrity
  5. Test with production data

Migration Script:

-- See docs/claude-architecture-redesign/21-migration-strategy.md
-- for full migration SQL

7.5 Production Deployment

Checklist:

  • All tests pass on main branch
  • Staging migration successful
  • UAT sign-off received
  • Deployment runbook reviewed
  • Rollback plan documented
  • Monitoring and alerts configured
  • Database backup verified

Deployment Steps:

  1. Announce maintenance window
  2. Backup production database
  3. Deploy new code to VPS
  4. Run migrations
  5. Restart application
  6. Smoke test critical paths
  7. Monitor for errors (15 minutes)
  8. Announce completion

Rollback Plan: If critical issues found:

  1. Restore database from backup
  2. Revert code deployment to previous version
  3. Restart application
  4. Verify old system operational

Acceptance Criteria

  • All integration tests pass
  • Performance benchmarks met
  • UAT participants approve
  • Staging migration successful
  • Production deployment successful
  • No critical bugs in first 24 hours
  • Monitoring shows stable metrics

Deliverables

  1. Integration test suite
  2. Performance test report
  3. UAT sign-off documentation
  4. Migration runbook
  5. Deployment runbook
  6. Post-deployment monitoring report

Risks & Mitigations

Risk Impact Mitigation
Migration fails on production data Critical Test on staging with production clone first
Performance issues at scale High Load testing, query optimization
UAT uncovers major issues High Allow 1-2 weeks buffer for fixes
Rollback needed Medium Practice rollback on staging, clear procedures

Critical Path Summary

The critical path (longest dependency chain) is:

Phase 0 (1 week) → Phase 1 (2 weeks) → Phase 2 (2 weeks) → Phase 6 (1 week) → Phase 7 (1 week)
Total: 7 weeks minimum

Parallel work streams add 2 weeks:

  • Phase 3 (admin UI) overlaps with Phase 2
  • Phase 4 (participant UI) overlaps with Phase 3
  • Phase 5 (awards) overlaps with Phase 4

Total estimated time: 8-9 weeks


Per-Phase File Checklist

Phase 0: Contract Freeze

Create:

  • src/types/competition.ts
  • src/types/round.ts
  • src/types/round-configs.ts
  • src/types/jury-group.ts
  • src/types/submission-window.ts
  • src/types/winner-confirmation.ts
  • src/types/advancement-rule.ts
  • src/lib/feature-flags.ts
  • tests/helpers-redesign.ts

Modify:

  • docs/claude-architecture-redesign/api-contracts.md
  • CLAUDE.md

Phase 1: Schema & Runtime Foundation

Create:

  • src/server/routers/competition.ts
  • src/server/routers/round.ts
  • src/server/routers/jury-group.ts
  • src/server/routers/submission-window.ts
  • src/server/services/round-engine.ts
  • tests/unit/competition.test.ts
  • tests/unit/round.test.ts
  • tests/unit/jury-group.test.ts
  • tests/unit/submission-window.test.ts
  • tests/unit/round-engine.test.ts
  • prisma/migrations/XXXXXX_add_competition_round_jury_models/migration.sql

Modify:

  • prisma/schema.prisma (add all new models)
  • src/server/routers/_app.ts (register new routers)

Phase 2: Backend Orchestration

Create:

  • src/server/services/round-assignment.ts
  • src/server/services/submission-round-manager.ts
  • src/server/services/mentor-workspace.ts
  • src/server/services/winner-confirmation.ts
  • tests/integration/assignment-service.test.ts
  • tests/integration/submission-round-manager.test.ts
  • tests/integration/mentor-workspace.test.ts
  • tests/integration/winner-confirmation.test.ts

Modify:

  • src/server/services/stage-filtering.ts (update references)
  • src/server/services/live-control.ts (add category windows)

Rename:

  • src/server/services/stage-assignment.tssrc/server/services/round-assignment.ts

Phase 3: Admin Control Plane

Create:

  • src/app/(admin)/competitions/new/page.tsx
  • src/app/(admin)/competitions/[id]/rounds/page.tsx
  • src/app/(admin)/competitions/[id]/jury-groups/page.tsx
  • src/app/(admin)/competitions/[id]/submission-windows/page.tsx
  • src/components/admin/competition-wizard/ (6 step components)
  • src/components/admin/rounds/ (list, card, editor, config editors)
  • src/components/admin/jury-groups/ (list, card, member manager)
  • src/components/admin/submission-windows/ (list, editor, requirement manager)

Modify:

  • src/app/(admin)/stage-manager/[stageId]/page.tsx (add round support)

Phase 4: Participant Journeys

Create:

  • src/app/(applicant)/mentoring/page.tsx
  • src/app/(mentor)/dashboard/page.tsx
  • src/app/(mentor)/workspace/[projectId]/page.tsx
  • src/components/applicant/SubmissionTimeline.tsx
  • src/components/applicant/WindowUploader.tsx
  • src/components/applicant/MentoringWorkspace.tsx
  • src/components/applicant/workspace/ (file list, upload, comments)
  • src/components/jury/MultiRoundAssignments.tsx
  • src/components/jury/RoundTabs.tsx
  • src/components/jury/CrossRoundDocViewer.tsx
  • src/components/mentor/ (assignment list, workspace, promote dialog)

Modify:

  • src/app/(applicant)/dashboard/page.tsx
  • src/app/(applicant)/submit/page.tsx
  • src/app/(jury)/dashboard/page.tsx
  • src/app/(public)/live-finals/[slug]/page.tsx

Phase 5: Special Awards & Governance

Create:

  • src/app/(admin)/competitions/[id]/awards/page.tsx
  • src/app/(admin)/competitions/[id]/confirm-winners/page.tsx
  • src/app/(jury)/confirm-winners/page.tsx
  • src/components/admin/awards/ (list, editor, jury manager)
  • src/components/admin/winner-confirmation/ (proposal list, editor, approval status, override)
  • src/components/jury/WinnerApprovalCard.tsx
  • src/server/services/pdf-export.ts

Modify:

  • src/server/routers/special-award.ts

Phase 6: Platform-Wide Refit

Delete:

  • src/lib/feature-flags.ts
  • All legacy Pipeline/Track/Stage routers (if any standalone files)

Modify (search/replace in ALL files):

  • src/server/routers/*.ts (all routers)
  • src/server/services/*.ts (all services)
  • src/components/**/*.tsx (all components)
  • src/app/**/*.tsx (all pages)
  • src/types/*.ts (all types)
  • prisma/schema.prisma (drop old tables)
  • CLAUDE.md
  • README.md

Create:

  • prisma/migrations/XXXXXX_drop_legacy_pipeline_track_stage_models/migration.sql

Phase 7: Validation & Release

Create:

  • tests/integration/full-competition-flow.test.ts
  • docs/deployment-runbook.md
  • docs/rollback-plan.md
  • docs/performance-test-report.md
  • docs/uat-sign-off.md

Modify:

  • docs/architecture.md (final diagrams)

Rollback Points

Phase 0-1: Low Risk

  • Feature flags off → old system still operational
  • Rollback: Drop new tables, revert code

Phase 2: Medium Risk

  • Services not yet exposed to users
  • Rollback: Disable feature flags, revert code

Phase 3-4: Medium Risk

  • New UIs available but optional
  • Rollback: Hide admin pages, disable routes

Phase 5: High Risk

  • Winner confirmation in use
  • Rollback: Complex (may need data export/import)

Phase 6: CRITICAL — Point of No Return

  • After old tables dropped, rollback requires full database restore
  • Do not proceed with Phase 6 until Phases 1-5 fully validated

Phase 7: Production

  • Rollback: Restore database backup, revert deployment

Risk Register (All Phases)

Risk Phase Impact Probability Mitigation
Schema migration fails 1 Critical Low Additive-only, test on staging
Performance degradation 2 High Medium Benchmark early, optimize queries
Admin UI too complex 3 Medium Medium User testing, iterative refinement
Participant confusion 4 Medium High Clear messaging, onboarding tooltips
Award logic bugs 5 High Medium Extensive testing, audit logging
Missed legacy references 6 Critical Medium Comprehensive search, strict TypeScript
Production migration fails 7 Critical Low Staging rehearsal, backup plan
UAT uncovers blockers 7 High Medium Allow 2-week buffer for fixes

Conclusion

This 7-phase implementation plan provides a clear, dependency-aware path from contract freeze to production release. Each phase has defined goals, tasks, acceptance criteria, and deliverables. The critical path is 7 weeks, with parallel work streams extending to 8-9 weeks total.

Key success factors:

  1. Strict acceptance gates — Don't advance until criteria met
  2. Incremental testing — Test each phase thoroughly before proceeding
  3. Feature flags — Gradual rollout reduces risk
  4. Staging validation — Rehearse migrations and deployments
  5. Clear rollback plan — Know how to revert at each stage

Next steps:

  1. Review and approve this implementation plan
  2. Begin Phase 0 (contract freeze)
  3. Set up project tracking (Jira, Linear, etc.)
  4. Schedule weekly checkpoint meetings
  5. Begin development with Phase 1