---
phase: design
agent: design-advisor
agent_version: 1
generated_at: 2026-06-23T16:00:00+09:00
workstream: W2-2-심사위원 역할 권한(잼 스코프 게이트)
concerns:
  - "JamRoleGate.isJudge / 컨트롤러 헬퍼 시그니처는 최소 인자(session, jamId)로 명세했다. 구현 단계에서 인자 전부가 실제 사용되는지 재확인 필요(dead parameter → unused 경고 방지, 프로토콜 §11.2). 특히 isJudge 가 jamId(bigint) 만 받는지, jam 객체/slug 까지 받는지 — 본 설계는 jamId 최소 채택, 충돌체크 헬퍼 hasOwnEntry(session, jamId) 도 최소 2인자."
  - "신규 컨트롤러(JamJudgeAdminController) + 신규 매퍼(JamJudgesMapper) 의존 추가 — verification-strategies §30 에 따라 implementation 단계에서 test-compile 로 끝내지 말고 full ./mvnw -o test + BibimbapApplicationTests 에 신규 @Mapper @MockBean 수동 등록 의무. 누락 시 contextLoads NoSuchBeanDefinitionException. JamRoleGate 가 @Component 면 @MockBean 또는 실제 빈+의존 매퍼 MockBean 필요."
  - "신규 매퍼 SQL 은 DB-방언 계약(L2) 대상 — snake→camel 직접 alias(jj.created_at AS createdAt)가 일반 매퍼 표준(verification-strategies §33). 큰따옴표 alias 는 집계 VIEW 매퍼만(jam_judges 는 일반 매퍼 → 큰따옴표 금지). exists(EXISTS) 반환 boolean 매핑·listByJam JOIN users 표시필드 dev DB contract 실측 권장."
  - "★자기출품 충돌 규칙(심사위원=자기 출품작 점수입력 불가)은 본 설계가 '계약'으로 정의하고 enforce 는 W2-4 점수입력 컨트롤러 소관이다. 본 W2-2 는 충돌 판정 헬퍼(JamRoleGate.hasOwnEntry 또는 JamEntriesMapper.existsEntrantUser)만 제공·계약 고정. W2-4 가 점수입력 진입부에서 이 헬퍼를 소비하는지 verification 시점 재확인 필요(crossRefs)."
  - "심사위원 지정 게이트 = GAME_JAM_MANAGE 이며 인터셉터 exclude 경로는 W2-1 의 /admin/jams/** 와 동일 트리(/admin/jams/{id}/judges). W2-1 InterceptorConfig.excludePathPatterns(\"/admin/jams/**\") 가 이미 /admin/jams/{id}/judges 를 커버하므로 본 설계는 InterceptorConfig 를 추가 수정하지 않는다(W2-1 J-CONFIG 와 충돌 0). 단 W2-1 미배포 상태에서 본 컨트롤러만 배포되면 인터셉터 isAdmin 게이트에 SUBADMIN 이 막힘 — 배포 순서 의존(롤아웃 §순서)."
  - "심사위원 자기출품 충돌 enforce 시점이 '지정 시점' 이 아니라 '점수입력 시점'(W2-4)이다. 따라서 자기 출품작이 있는 유저도 심사위원으로 지정될 수 있고(다른 출품작은 심사 가능), 자기 출품작에 대해서만 점수입력이 거부된다. 지정 자체를 막지 않는 이유는 §충돌 규칙 계약에 명시 — 구현이 지정 시점에 막지 않도록 주의."
concerns_checked: true
self_verification:
  checklist_passed: true
references:
  requirements: docs/work-log/2026-06-23-w2-w4-feature-skeletons.md
  research: .atp/work-session/20260623-104307/research/W2-W4-grounding.md
  adrs:
    - .atp/work-session/20260623-104307/implementation/W2-1-jam-entity-design.md
    - .atp/work-session/20260623-104307/implementation/W2-3-eval-freeze-design.md
    - .atp/work-session/20260622-180054/implementation/W1-design.md
    - docs/work-log/2026-06-17-jam-platform-roadmap.md
    - docs/development/verification-strategies.md
    - docs/rbac-ddl.sql
    - docs/jam-ddl.sql
---

# 설계: W2-2 — 심사위원 역할 권한 (잼 스코프 게이트 / jam_judges + 지정 CRUD + 자기출품 충돌 계약)

> ★보안 워크스트림(권한 스코프). 핵심 보안 단언: **전역 RBAC(user_permissions)는 불변** — 잼 회차별 역할은 **잼 스코프 조인테이블(jam_judges)** 로 표현하고, 판정은 **잼 스코프 게이트(JamRoleGate.isJudge(session, jamId))** 로 한다. 전역 PermissionGate 와 별도 축. W2-4 점수입력이 본 게이트 + 자기출품 충돌 규칙을 소비.

## 목표 / 비목표

### 목표 (FR/NFR 추적 — 골자 W2-2)
- **G1 잼 스코프 역할 모델**: 잼 회차별 심사위원 역할을 **별도 `jam_judges` 테이블**(jam_id, user_id)로 표현. 전역 `user_permissions`(RBAC) 무변경 — 잼별 역할을 전역 권한 모델에 끼워넣지 않음(스코프 갭 정석 해소, QG-W2-A 채택 (b)).
- **G2 잼 스코프 게이트**: `JamRoleGate.isJudge(session, jamId)` — `jam_judges` 조회로 잼별 심사위원 자격 판정. W1 `PermissionGate`(전역 키 판정)와 **별도 축**. 리소스(jamId) 인자 보유.
- **G3 심사위원 지정/해제 = GAME_JAM_MANAGE 게이트**: `/admin/jams/{jamId}/judges` 추가/제거. 진입부에서 `PermissionGate.has(session, GAME_JAM_MANAGE)` 통과 요구(W1 인프라 위, **임시 role 직접체크 금지**). 임명 주체 = 잼 관리자(ADMIN 또는 SUBADMIN+GAME_JAM_MANAGE).
- **G4 자기출품 충돌 계약**: 심사위원은 같은 잼 **자기 출품작** 점수 입력 불가(자기출품 충돌 회피). 본 설계는 **충돌 판정 헬퍼 + 계약**을 제공하고, enforce 는 **W2-4 점수입력 컨트롤러**가 수행(계약 명시).
- **G5 역할 수명**: 잼 종료 후 `jam_judges` 레코드 **잔존**(이력). 점수입력 게이트의 활성 여부는 W2-3 평가기간 게이트(EVAL + 기간)가 별도로 통제 — 역할 자체는 만료 회수하지 않음.
- **G6 심사위원 자격**: 누구나 지정 가능(일반 USER 포함). 권한은 **잼별 부여**(전역 role 무관). USER 도 특정 잼 심사위원이 될 수 있음.
- **G7 지정 현황 조회**: 잼 관리자가 잼별 심사위원 목록을 조회(지정/해제 UI 소스).
- **NFR**: 상태변경(지정/해제) CSRF 전수, `#{}` 바인딩(`${}` 금지), 입력 검증, 비파괴 멱등 마이그레이션, DDL 권위=docs/*-ddl.sql.

### 비목표 (스코프 밖)
- **심사 점수 입력 API·UI·집계** — **W2-4 소유**. 본 설계는 `JamRoleGate.isJudge` 게이트 + 자기출품 충돌 헬퍼 **계약만** 제공. W2-4 가 점수입력 진입부에서 소비.
- **평가기간 게이트(EVAL + now∈[eval_start,eval_end])** — **W2-3 동결 계약(F6)**. 본 설계는 심사위원 *자격* 판정만, 평가 *시점* 게이트는 W2-3 소관(점수입력 시 두 게이트 AND).
- **잼 엔티티/CRUD/출품(jams/jam_entries/jam_teams)** — **W2-1 소유**. 본 설계는 jam_id FK 참조 + jam_entries 활성행 조회만.
- **전역 RBAC 모델 변경(user_permissions scope 컬럼 추가)** — 채택 안 함(QG-W2-A (a) 기각). 전역 모델 불변.
- **심사위원 인원 제한·정원·초대 워크플로** — 1차 미포함(누구나 지정 가능, 인원 무제한). 정원 정책은 후속(concern 아님 — 명시적 비목표).
- **InterceptorConfig 수정** — W2-1 이 이미 `/admin/jams/**` exclude 추가(W2-1 J-CONFIG). `/admin/jams/{id}/judges` 는 그 트리 하위라 추가 수정 불요(아래 §인터셉터 연동).

---

## 개요

bibimbap 은 Spring Boot WAR + 톰캣 in-memory HttpSession + MyBatis annotation `@Mapper`(`#{}` only) + JSP 스택이다. 현재 `user_permissions` 는 **전역(글로벌) 권한 모델**이다 — 컬럼 (id/user_id/permission_key/granted_by/created_at), UNIQUE(user_id, permission_key), **scope/resource_id/jam_id 컬럼 부재**(grounding R-A, db/schema.sql:326-347). `PermissionGate.has(session, permissionKey)` 도 리소스 인자가 없다(PermissionGate.java:22 직접 확인). 따라서 "잼 회차별 심사위원 역할"은 전역 권한 모델 위에 그대로 얹히지 않는다(스코프 갭).

본 설계는 **별도 잼 스코프 조인테이블 `jam_judges`(jam_id, user_id)** 를 신규 도입하고(QG-W2-A 채택안 (b)), **잼 스코프 게이트 `JamRoleGate.isJudge(session, jamId)`** 를 제공한다. 전역 `PermissionGate` 는 무변경 — 두 게이트는 **서로 다른 축**(전역 권한 키 vs 잼 리소스 역할)이다. 심사위원 **지정/해제**는 잼 관리자 권한(`GAME_JAM_MANAGE`)으로 보호하고(W1 게이트 위, 임시 role 직접체크 금지), **자기출품 충돌**은 W2-4 가 소비할 계약으로 고정한다.

확정된 정석 결정(전제 — 재논의 금지):
- **별도 테이블 (b) 채택**: 전역 RBAC 에 scope 컬럼을 끼워넣는 (a)안은 전역 모델·게이트 시그니처를 침습적으로 바꾸고(회귀 위험) 잼 외 다른 스코프 역할이 생길 때마다 컬럼이 늘어난다. (b) `jam_judges` 는 잼 스코프 역할의 자연스러운 정규화이며 전역 모델 불변(회귀 0). 하이브리드 (c)는 over-engineering.
- **게이트 축 분리**: `JamRoleGate.isJudge(session, jamId)` 는 잼 리소스 인자를 받는 별도 게이트. 전역 `PermissionGate.has(session, key)` 시그니처를 건드리지 않는다(W1 회귀 0).
- **지정 게이트 = GAME_JAM_MANAGE**: 심사위원 지정은 잼 관리 행위 → W2-1 의 `/admin/jams/**` enforcement 패턴(인터셉터 exclude + 컨트롤러 게이트 헬퍼)을 그대로 재사용.
- **충돌 enforce 시점 = 점수입력(W2-4)**: 지정 시점이 아니라 점수입력 시점에 자기 출품작을 거부. 자기 출품작 보유 유저도 심사위원 지정 가능(다른 작품 심사 가능, 자기 작품만 점수입력 거부).

가장 까다로운 두 난제 확정:
- **난제1 (스코프 게이트 축 분리 — 전역 모델 보호)**: 잼별 역할을 전역 `user_permissions` 에 표현하려면 (user_id, permission_key) 에 scope/jam_id 가 필요한데, 이는 전역 UNIQUE(user_id, permission_key)·`PermissionGate.has(session, key)` 2인자 시그니처를 깬다(W1 회귀). 본 설계는 **전역 권한 축(PermissionGate, 키 기반, 잼 무관)** 과 **잼 스코프 역할 축(JamRoleGate, jam_judges 조회, jamId 기반)** 을 명확히 분리한다. 심사위원 자격은 전역 권한이 아니라 잼 리소스 멤버십이므로 별도 축이 정석. epoch 전파(W1 결정4)는 전역 권한에만 적용 — 잼 역할은 jam_judges 직접 조회(요청당 조회, 캐시 안 함 — 잼 역할 변경 즉시 반영, 별도 epoch 불요).
- **난제2 (자기출품 충돌 — 개인/팀 entrant 모두 커버)**: W2-1 `jam_entries` 는 entrant_type('USER'/'TEAM') + entrant_user_id(개인) / jam_team_id(팀) XOR 구조다. "자기 출품작" 은 ① 개인 출품: `jam_entries.entrant_user_id == 심사위원 userId`, ② 팀 출품: 심사위원이 그 팀(`jam_entries.jam_team_id`)의 멤버(`jam_team_members.user_id == 심사위원`). 본 설계는 충돌 판정 계약을 **두 경로 모두**로 정의하고, 판정 헬퍼 `hasOwnEntry(session, jamId)`(또는 매퍼 `existsConflictEntry(jamId, userId)`) 를 제공한다. W2-4 가 점수입력 대상 game_id 에 대해 "이 출품작이 심사위원 본인 것인가"를 검사 — 본 W2-2 는 게임 단위 충돌 판정 매퍼 `isOwnEntry(jamId, gameId, userId)` 시그니처를 동결 제공.

---

## 핵심 결정 요약 (전제 — 재논의 금지. orchestrator 확정값)

| 결정 | 확정값 | 본 설계의 구체화 |
|---|---|---|
| J1 역할 모델 | 별도 `jam_judges` 테이블 | jam_judges(id, jam_id FK, user_id FK, assigned_by FK, created_at, UNIQUE(jam_id,user_id)). 전역 user_permissions 불변 |
| J2 게이트 | `JamRoleGate.isJudge(session, jamId)` | 잼 스코프 게이트(별도 축). jam_judges 조회 판정. PermissionGate(전역)와 분리 |
| J3 지정 게이트 | GAME_JAM_MANAGE | `/admin/jams/{jamId}/judges` 추가/제거 = `PermissionGate.has(session, GAME_JAM_MANAGE.name())`. 임시 role 직접체크 금지 |
| J4 자기출품 충돌 | 점수입력 시 자기출품작 거부 | 본 설계=충돌 판정 헬퍼 + 계약. enforce=W2-4. 개인+팀 entrant 모두 커버 |
| J5 역할 수명 | 잼 종료 후 잔존(이력) | jam_judges 만료 회수 없음. 점수입력 활성=W2-3 평가기간 게이트가 별도 통제 |
| J6 자격 | 누구나 지정(USER 포함) | 전역 role 무관. 권한은 잼별 부여 |
| J7 충돌 enforce 시점 | 점수입력 시점(지정 아님) | 자기출품작 있어도 지정 가능. 자기 작품만 점수입력 거부 |

---

## 데이터 모델 (DDL)

> 권위 = **신규 파일 `docs/jam-judge-ddl.sql`** (apply-local-ddl.sh 가 docs/*-ddl.sql 알파벳 글롭으로 멱등 적용, ON_ERROR_STOP, search_path=dev). `db/schema.sql` 에 동기 사본(아래 §schema.sql 반영). 선례: W1 docs/rbac-ddl.sql, W2-1 docs/jam-ddl.sql, W2-3 docs/jam-eval-ddl.sql(직접 확인). **jams/users 변경 없음**(FK 참조만). 멱등: CREATE TABLE/SEQUENCE IF NOT EXISTS, DO $$ guard, CREATE UNIQUE INDEX IF NOT EXISTS. 타입은 기존 스타일(bigint/varchar/timestamptz).
>
> ⚠️ **알파벳 글롭 순서**: `apply-local-ddl.sh` 가 docs/*-ddl.sql 을 알파벳순 적용. `jam_judges` FK 가 `jams`(W2-1 docs/jam-ddl.sql) + `users`(기존) 를 참조하므로 jam-ddl 이 jam-judge 보다 **먼저** 적용돼야 한다. 알파벳: 공통 prefix `jam-` 뒤 `d`(jam-**d**dl) < `j`(jam-**j**udge) → jam-ddl 먼저 적용 보장(롤아웃 §순서 재확인). game-reviews-ddl(`g`)·rbac-ddl(`r`)과도 무충돌.

### 신규 파일: `docs/jam-judge-ddl.sql`

```sql
-- W2-2 심사위원 역할 권한(잼 스코프). 멱등. db/apply-local-ddl.sh 로 실행 DB 비파괴 적용.
-- 선행: docs/jam-ddl.sql(jams — 알파벳 글롭 순 jam-ddl 먼저 적용).
-- 전역 user_permissions(RBAC) 변경 없음 — 잼 회차별 역할은 잼 스코프 조인이 정석.
-- 추가만, 파괴 없음.

-- ===========================================================================
-- 1) jam_judges (잼별 심사위원. 잼 스코프 역할. 전역 권한과 별도 축)
-- ===========================================================================
CREATE SEQUENCE IF NOT EXISTS "jam_judges_id_seq";
CREATE TABLE IF NOT EXISTS "jam_judges" (
    "id"          bigint DEFAULT nextval('jam_judges_id_seq'::regclass) NOT NULL,
    "jam_id"      bigint NOT NULL,                          -- 잼 회차(FK jams)
    "user_id"     bigint NOT NULL,                          -- 심사위원(FK users; 누구나 가능)
    "assigned_by" bigint,                                   -- 지정 관리자(FK users; 감사 보조, nullable)
    "created_at"  timestamp with time zone DEFAULT now() NOT NULL,
    PRIMARY KEY ("id")
);
ALTER SEQUENCE "jam_judges_id_seq" OWNED BY "jam_judges"."id";
DO $$
BEGIN
    IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_judges_jam_fkey') THEN
        ALTER TABLE "jam_judges" ADD CONSTRAINT "jam_judges_jam_fkey"
            FOREIGN KEY ("jam_id") REFERENCES "jams" ("id");
    END IF;
    IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_judges_user_fkey') THEN
        ALTER TABLE "jam_judges" ADD CONSTRAINT "jam_judges_user_fkey"
            FOREIGN KEY ("user_id") REFERENCES "users" ("id");
    END IF;
    IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_judges_assigned_by_fkey') THEN
        ALTER TABLE "jam_judges" ADD CONSTRAINT "jam_judges_assigned_by_fkey"
            FOREIGN KEY ("assigned_by") REFERENCES "users" ("id");
    END IF;
END
$$;
-- 같은 잼에 같은 유저 중복 지정 방지(멱등 지정). 잼 종료 후 잔존(J5) — soft delete 없음(이력=행 존재).
-- 해제는 hard DELETE(역할 회수). 재지정은 다시 INSERT.
CREATE UNIQUE INDEX IF NOT EXISTS "ux_jam_judges_jam_user"
    ON "jam_judges" ("jam_id", "user_id");
CREATE INDEX IF NOT EXISTS "idx_jam_judges_jam"
    ON "jam_judges" ("jam_id");
```

> **soft delete 미채택 근거(J5 정합)**: jam_judges 는 `is_delete` 를 두지 않는다. "잔존 이력"의 단위는 *잼 종료 후에도 행이 남는다*(만료 회수 안 함)는 의미이고, **해제(관리자가 명시적으로 심사위원 자격 박탈)** 는 역할 회수이므로 hard DELETE 가 정석(행 존재 = 현재 심사위원). soft delete 를 두면 isJudge 판정마다 `is_delete IS NOT TRUE` 필터가 필요하고 UNIQUE 도 partial 이어야 해 복잡도만 늘린다. 해제 이력이 필요하면 후속에 별도 감사 로그(비목표). UNIQUE(jam_id, user_id) full 로 멱등 지정 보장.

### `db/schema.sql` 반영 (최초 기동 1회 자동 주입 — docs/jam-judge-ddl.sql 의 사본)
- W2-1 의 jams/jam_entries 블록 **뒤**(jams 가 FK 참조 대상이므로 schema.sql 순차 실행상 jams 가 먼저 정의돼야 함)에 위 1번을 **신설 블록**으로 추가.
- 헤더 주석: `-- 심사위원 역할 W2-2 (권위 DDL — docs/jam-judge-ddl.sql 와 동일. 잼 스코프 역할)` — game_reviews 블록 schema.sql:128 의 `(권위 DDL — docs/...-ddl.sql 와 동일)` 선례와 동형(직접 확인).
- 반영 방식: **docs/jam-judge-ddl.sql 이 권위, schema.sql 은 사본**. 두 곳에 동일 멱등 DDL.

### 전역 RBAC / jams / users 무변경 확인 (J1 보안 단언)
- `user_permissions`/`permissions`/`users` 테이블 변경 0. 잼 스코프 역할은 전부 `jam_judges` 가 보유 → W1 RBAC 동작(전역 권한 판정·epoch 전파) 회귀 0.
- `jams`/`jam_entries`/`jam_teams` 변경 0. FK 로 jams.id 참조만. W2-1 잼 동작 회귀 0.

---

## 외부 계약 (API)

> 공통: 모든 상태변경(지정/해제)은 `CsrfTokens.isValid(request)` 검증(없으면 403 + `CsrfTokens.errorBody()`, CsrfTokens.java 확인). 응답은 RecruitController/W2-1 패턴 — 읽기=JSP 뷰이름 반환 또는 JSON 조회, 쓰기=`ResponseEntity<Map<String,Object>>`(status/message). 관리자 API 는 진입부에서 `PermissionGate.has(session, GAME_JAM_MANAGE.name())` 게이트 통과 후 본문 수행(W2-1 requireJamManage 헬퍼 재사용 후보).

### 401 vs 403 vs 422 정책 (W1-design / W2-1 / W2-3 과 일치)
- **미인증**(세션 `userId` 없음): API 401 JSON `{status:401, message:"로그인이 필요합니다."}`. 페이지는 `redirect:/login`.
- **인증·미인가**(GAME_JAM_MANAGE 없음): 403 JSON `{status:403, message:"권한이 없습니다."}`(리다이렉트 금지).
- **자기출품 충돌**(W2-4 점수입력에서 본인 출품작): **422** JSON `{status:422, message:"본인 출품작은 심사할 수 없습니다."}` (인가는 됐으나 도메인 충돌 → 403 아님 422. W2-3 의 "평가기간 외=422" 정책과 동형 — 인가/시점/충돌은 422 계열).
- **CSRF 실패**: 403 + `CsrfTokens.errorBody()`.

### 심사위원 지정/해제/조회 (관리자 API — CSRF + GAME_JAM_MANAGE 게이트)
| 액션 | method | path | 요청 | 응답(200) | 에러 |
|---|---|---|---|---|---|
| 지정 | POST | `/admin/jams/{jamId}/judges` | userId | `{status:200, message, jamId, userId}` | 401/redirect, 403(CSRF/권한), 404(잼/대상유저 없음), 409(이미 심사위원) |
| 해제 | POST | `/admin/jams/{jamId}/judges/{userId}/remove` | (path) | `{status:200, message, jamId, userId, removed:true}` | 401, 403, 404(지정 안 됨) |
| 목록 조회 | GET | `/admin/jams/{jamId}/judges` | (없음) | `{status:200, judges:[{userId, displayName, assignedBy, createdAt}]}` | 401/redirect, 403 |

- **지정 게이트(J3) enforcement**: 위 3액션 전부 진입부에서 `gate.isAuthenticated(session)`(401/redirect) → `gate.has(session, GAME_JAM_MANAGE.name())`(403) 2단계. PermissionGate.isAuthenticated(:47)/has(:22) 직접 확인. W2-1 의 `requireJamManage` private 헬퍼와 동일 패턴(재사용 권장 — 중복 0).
- **해제 = hard DELETE**: `removed:true` 응답. 멱등(이미 해제됐으면 404 또는 removed:false — 본 설계는 404 채택, 행 없음).
- **누구나 지정 가능(J6)**: 지정 대상 userId 의 전역 role 검사 없음. USER/SUBADMIN/ADMIN 무관 지정 가능. 단 대상 user 가 실재(`users` 활성)하는지만 검증(404).
- **자기출품 충돌은 지정에서 막지 않음(J7)**: 대상 유저가 그 잼에 출품했어도 지정 허용. 충돌은 점수입력(W2-4)에서만 거부.

### 점수입력 게이트 계약 (J2/J4 — W2-4 소비, 권위)
> 본 W2-2 는 점수입력 API 를 신설하지 않는다. 아래는 W2-4 가 점수입력 진입부에서 **준수해야 할 계약**이다.

- **심사위원 자격(J2)**: `JamRoleGate.isJudge(session, jamId)` true 여야 점수입력 허용. false → 403. (전역 GAME_JAM_MANAGE 와 무관 — 잼 스코프 역할.)
- **자기출품 충돌(J4)**: 점수입력 대상 game_id 에 대해 `JamRoleGate.isOwnEntry(jamId, gameId, judgeUserId)` true 면 거부(422 "본인 출품작은 심사할 수 없습니다"). 개인 출품(entrant_user_id==judge) + 팀 출품(judge 가 그 팀 멤버) 모두 충돌(난제2).
- **평가기간 게이트(W2-3 F6)**: `jam.status=='EVAL' AND now()∈[eval_start_at, eval_end_at]` (W2-3 소관). W2-4 진입부 게이트 순서 = ① CSRF → ② 로그인(401) → ③ 평가기간(W2-3, 422) → ④ isJudge(W2-2, 403) → ⑤ 자기출품 충돌(W2-2, 422) → 점수입력. (순서는 W2-4 결정 — 본 계약은 4·5 가 W2-2 소비점임만 고정.)

---

## 인터셉터 / 게이트 연동 (J3 — W2-1 enforcement 재사용)

### 문제 / 전제
- `RbacInterceptor.preHandle` 은 `/admin/**` 전체에 `isAdmin(session)` 만 검사(RbacInterceptor.java:34, PermissionGate.isAdmin:55-62 — role==ADMIN 만 true). SUBADMIN+GAME_JAM_MANAGE 는 인터셉터에 막힌다.
- W2-1 이 이 갭을 `InterceptorConfig.addPathPatterns("/admin/**").excludePathPatterns("/admin/jams/**")` 로 해소(W2-1 D4-A, J-CONFIG). `/admin/jams/{jamId}/judges` 는 `/admin/jams/**` 트리 하위이므로 **W2-1 의 exclude 가 이미 커버**한다.

### 확정 (J3-A: InterceptorConfig 추가 수정 없음 + 컨트롤러 게이트 헬퍼)
- **InterceptorConfig 무수정**: W2-1 의 `/admin/jams/**` exclude 가 `/admin/jams/{id}/judges` 를 포함 → 본 설계는 InterceptorConfig 를 건드리지 않는다(W2-1 J-CONFIG 와 충돌 0, 중복 exclude 0).
- **JamJudgeAdminController 진입부 게이트 헬퍼**: 각 액션 시작에서 `requireJamManage(session)`(W2-1 헬퍼 재사용 또는 동형):
  1. `gate.isAuthenticated(session)` 거짓 → 페이지면 redirect:/login, API 면 401.
  2. `gate.has(session, PermissionKeys.GAME_JAM_MANAGE.name())` 거짓 → 403.
  3. 통과 후 본문.
- **이유**: 심사위원 지정은 잼 관리 행위 → W2-1 잼 CRUD 와 동일 권한·동일 경로 트리. 별도 enforcement 메커니즘을 만들 이유 0(W2-1 선례 재사용, 중복 0).
- **배포 순서 의존(concern 5)**: W2-1 의 exclude 가 배포되기 전 본 컨트롤러만 배포되면 인터셉터 isAdmin 게이트에 SUBADMIN 이 막힌다. → W2-1 이후 배포(롤아웃 §순서).

### epoch 전파 연동 (W1 결정4 — 전역 권한만)
- `gate.has` 내부 `refreshIfStale`(PermissionGate.java:86) 가 요청당 전역 epoch 대조 → ADMIN 이 SUBADMIN 에게 GAME_JAM_MANAGE 부여/회수 시 즉시 반영(W1 메커니즘 그대로, 본 설계 추가 작업 0).
- **잼 역할(jam_judges)은 epoch 미사용**: `JamRoleGate.isJudge` 는 요청당 jam_judges 직접 조회(캐시 안 함). 심사위원 지정/해제는 다음 요청에서 즉시 반영(별도 epoch 스탬프 불요 — 조회가 곧 최신). 전역 권한 캐시(세션 permissions Set)와 다른 정책인 이유: 잼 역할은 세션에 캐시하지 않으므로 stale 문제가 없다(저비용 단일 인덱스 EXISTS 조회).

---

## 시퀀스 (주요 플로우 의사코드)

### S1. 심사위원 지정 → 해제
```
[잼 관리자(ADMIN 또는 SUBADMIN+GAME_JAM_MANAGE) 세션] POST /admin/jams/42/judges (CSRF, userId=7)
  → InterceptorConfig: /admin/jams/** exclude(W2-1) → 인터셉터 미개입
  → JamJudgeAdminController.assignJudge(42, userId=7)
      → requireJamManage(session): isAuthenticated? gate.has(GAME_JAM_MANAGE)?  (아니면 401/403)
      → CsrfTokens.isValid(request)                       (아니면 403 errorBody)
      → jam = jamsMapper.getById(42)                       (없으면 404)   # W2-1 매퍼 소비
      → target = usersMapper.getUser(7)                    (없으면 404)
      → jamJudgesMapper.exists(42, 7)?                      (이미면 409 이미 심사위원)
      → jamJudgesMapper.insert(42, 7, assignedBy=actorId)  (UNIQUE 보장 — race 시 catch DuplicateKey→409)
      → 200 {jamId:42, userId:7}
   # 자기출품 충돌은 지정에서 막지 않음(J7) — target 이 42 에 출품했어도 지정 허용.

[잼 관리자] POST /admin/jams/42/judges/7/remove (CSRF)
  → JamJudgeAdminController.removeJudge(42, 7)
      → requireJamManage + CSRF
      → affected = jamJudgesMapper.delete(42, 7)            # hard DELETE(역할 회수, J5)
      → affected == 0 → 404 (지정 안 됨)
      → 200 {removed:true}
```

### S2. 점수입력 게이트 소비 (W2-4 — 본 계약 검증용. 본 설계 미구현)
```
[유저 7 세션] POST /jams/{slug}/scores (CSRF, gameId=88, {criterionKey:score,...})   # W2-4 컨트롤러
  → CsrfTokens.isValid                                     (아니면 403)
  → userId = sessionUserId(=7)                             (없으면 401)
  → jam = jamsMapper.getBySlug(slug)                       (없으면 404)
  → [W2-3 F6] jam.status=='EVAL' AND now∈[eval_start,eval_end]?  (아니면 422 평가기간 외)
  → [W2-2 J2] jamRoleGate.isJudge(session, jam.id)?        (아니면 403 심사위원 아님)
  → [W2-2 J4] jamRoleGate.isOwnEntry(jam.id, gameId=88, userId=7)?
        true → 422 "본인 출품작은 심사할 수 없습니다"        # 자기출품 충돌(개인 또는 팀멤버)
  → jamScoresMapper.upsertScore(...)  (W2-3 동결 매퍼)
  → 200 {message}
```

### S3. isJudge / isOwnEntry 판정 내부 (JamRoleGate)
```
JamRoleGate.isJudge(session, jamId):
  userId = sessionUserId(session)                          # PermissionGate.sessionUserId 와 동형
  if userId == null: return false                          # 미인증은 심사위원 아님
  return jamJudgesMapper.exists(jamId, userId)             # 단일 인덱스 EXISTS(ux_jam_judges_jam_user)

JamRoleGate.isOwnEntry(jamId, gameId, userId):             # 게임 단위 자기출품 충돌(난제2)
  # jam_entries 활성행에서 (jamId, gameId) 출품작의 entrant 가 userId 본인인지.
  # 개인: entrant_user_id == userId / 팀: jam_team_members 에 (jam_team_id, userId) 존재.
  return jamEntriesMapper.isOwnEntry(jamId, gameId, userId)  # 아래 매퍼 SQL 계약
```

---

## 파일 영향 맵

> 소유권 분할 가이드(implementation-advisor worker 단위 후보):
> **K-SCHEMA**(DDL/schema 동기) · **K-DOMAIN**(data POJO) · **K-MAPPER**(JamJudgesMapper + JamEntriesMapper 충돌판정 메서드 추가) · **K-GATE**(JamRoleGate) · **K-ADMIN**(JamJudgeAdminController + JSP 또는 W2-1 admin-jam JSP 확장).
> 의존: K-SCHEMA → K-DOMAIN → K-MAPPER → {K-GATE, K-ADMIN}. K-GATE 는 W2-4 점수입력의 공통 선행(계약).
> **W2-1 의존**: jamsMapper.getById/getBySlug(존재), JamEntryData/jam_entries 스키마, InterceptorConfig exclude. K-MAPPER 의 isOwnEntry 는 W2-1 의 JamEntriesMapper 에 메서드 추가(소유권 경계 — concern·crossRefs).

| 변경 유형 | 경로 | 역할 | 소유 |
|---|---|---|---|
| 신규 | `docs/jam-judge-ddl.sql` | 권위 DDL(jam_judges). apply-local-ddl.sh 자동 적용(알파벳: jam-ddl 뒤) | K-SCHEMA |
| 수정 | `db/schema.sql` | jam_judges 블록 추가(jam-judge-ddl 사본). jams 블록 뒤. 전역 RBAC/jams 무변경 | K-SCHEMA |
| 신규 | `src/main/java/com/pandoli365/bibimbap/data/JamJudgeData.java` | jam_judges 행 + JOIN users 표시필드(userId/displayName/assignedBy/createdAt) | K-DOMAIN |
| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/JamJudgesMapper.java` | `@Mapper` 지정 insert/delete/exists/listByJam(JOIN users)(`#{}`, snake→camel 직접 alias) | K-MAPPER |
| 수정 | `src/main/java/com/pandoli365/bibimbap/mapper/JamEntriesMapper.java` | `isOwnEntry(jamId, gameId, userId)` 추가(자기출품 충돌 판정, 개인+팀멤버 OR). **W2-1 소유 매퍼 — 메서드 추가**(crossRefs) | K-MAPPER |
| 신규 | `src/main/java/com/pandoli365/bibimbap/security/JamRoleGate.java` | 잼 스코프 게이트 — `isJudge(session, jamId)`/`isOwnEntry(jamId, gameId, userId)` (별도 축, @Component) | K-GATE |
| 신규 | `src/main/java/com/pandoli365/bibimbap/controller/JamJudgeAdminController.java` | `/admin/jams/{jamId}/judges` 지정/해제/조회(GAME_JAM_MANAGE 게이트 헬퍼, CSRF) | K-ADMIN |
| 수정 | `src/main/webapp/WEB-INF/views/admin-jam-list.jsp` | 잼별 심사위원 지정/해제 폼·목록(CSRF hidden). **W2-1 소유 JSP — 섹션 추가**(또는 신규 admin-jam-judges.jsp). crossRefs | K-ADMIN |
| 수정 | `src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java` | 신규 JamJudgesMapper + JamRoleGate @MockBean 등록(contextLoads 보존, verification §30) | (검증) |
| 신규 | `src/test/.../JamJudgeAdminControllerTest.java` | 지정/해제/조회 + 401/403/CSRF/409/404 + GAME_JAM_MANAGE 게이트(ADMIN/SUBADMIN+키/무키) | (검증) |
| 신규 | `src/test/.../JamRoleGateTest.java` | isJudge(지정/미지정/미인증) + isOwnEntry(개인출품/팀멤버출품/타인출품) 단위 | (검증) |

> SSR 호출지점 영향(verification §영향맵): jam_judges 는 신규 테이블, JamRoleGate 는 신규 컴포넌트 → 기존 소비처 0 영향. JamEntriesMapper.isOwnEntry 는 신규 메서드라 기존 호출지점 깨짐 0(W2-1 메서드 보존). admin-jam-list.jsp 섹션 추가는 기존 폼 보존 + 추가만.

### 신규 함수 시그니처 (최소 인자 + 인라인 사용목적 — inflate 방지)
```java
// JamRoleGate — 잼 스코프 게이트(별도 축, @Component). 전역 PermissionGate 시그니처 무변경.
boolean isJudge(HttpSession session,    // userId 출처(세션) — 미인증이면 false
                long jamId)             // 잼 리소스 식별 — jam_judges 조회 키
boolean isOwnEntry(long jamId,          // 잼 식별
                   long gameId,         // 점수입력 대상 출품작
                   long judgeUserId)    // 충돌 판정 대상 심사위원 — 본인 출품작이면 true
// (isOwnEntry 가 session 이 아닌 judgeUserId 를 받는 이유: W2-4 가 이미 세션에서 userId 추출 후 호출하므로
//  게이트는 순수 판정만. session 재추출 중복 방지 — 최소 인자. isJudge 는 게이트 진입점이라 session 직수용.)

// JamJudgesMapper (@Mapper, #{} only, snake→camel 직접 alias)
int insert(long jamId,                  // 지정 잼
           long userId,                 // 심사위원
           long assignedBy)             // 지정 관리자(감사)
int delete(long jamId, long userId)     // 해제(hard DELETE, 역할 회수). 반환=affected rows(0이면 404)
boolean exists(long jamId, long userId) // 지정 여부(isJudge 소스 + 중복 지정 409 체크)
List<JamJudgeData> listByJam(long jamId)// 관리자 조회(JOIN users displayName)

// JamEntriesMapper 추가 (W2-1 소유 매퍼에 메서드 추가 — @Mapper, #{} only)
boolean isOwnEntry(long jamId,          // 잼
                   long gameId,         // 출품작
                   long userId)         // 본인 여부 판정 대상(개인 entrant_user_id 또는 팀멤버)
```
> inflate 마킹(concern 1): `isOwnEntry` 는 3인자 전부(jamId/gameId/userId) 충돌 판정 SQL 에 쓰인다(개인 OR 팀멤버 EXISTS). 구현에서 gameId 없이 jamId+userId 만으로 "잼 내 본인 출품 존재" 를 본다면 다른 시그니처(hasOwnEntryInJam(jamId, userId))가 되지만, W2-4 는 *점수입력 대상 game_id 가 본인 것인지*를 묻는 게임 단위 판정이 필요하므로 gameId 포함이 정석(다른 출품작은 심사 가능, 자기 작품만 거부 — J7). `assignedBy` 는 감사 컬럼 채움에 실사용(nullable이지만 컨트롤러가 actorId 전달).

### JamEntriesMapper.isOwnEntry SQL 계약 (난제2 — 개인+팀 OR)
```sql
-- 본인 출품작 충돌 판정: 활성 출품작 (jamId,gameId) 의 entrant 가 userId 본인인가.
-- 개인 출품: entrant_user_id == userId
-- 팀 출품:   jam_team_id 가 가리키는 팀에 userId 가 멤버(jam_team_members)
SELECT EXISTS(
    SELECT 1 FROM jam_entries e
    WHERE e.jam_id = #{jamId} AND e.game_id = #{gameId} AND e.is_delete IS NOT TRUE
      AND (
        (e.entrant_type = 'USER' AND e.entrant_user_id = #{userId})
        OR
        (e.entrant_type = 'TEAM' AND EXISTS(
            SELECT 1 FROM jam_team_members m
            WHERE m.jam_team_id = e.jam_team_id AND m.user_id = #{userId}
        ))
      )
)
```
> `#{}` 바인딩만, `${}` 0. jam_entries.entrant_type/entrant_user_id/jam_team_id + jam_team_members 는 W2-1 §데이터모델4·3 스키마. boolean 반환(EXISTS) — UserPermissionsMapper.exists 선례(:21-29)와 동형.

---

## 대안 비교

| 주제 | 안 | 장점 | 단점 | 채택 |
|---|---|---|---|---|
| 잼 역할 모델 | (A) 별도 jam_judges 테이블 | 전역 RBAC 불변(회귀 0), 잼 스코프 정규화, 다잼 자연 표현 | 테이블 1개 + 게이트 1개 추가 | **채택(J1, QG-W2-A(b))** |
| | (B) user_permissions 에 scope/jam_id 컬럼 추가 | 권한 단일 모델 | 전역 UNIQUE/PermissionGate.has 시그니처 침습(W1 회귀), 잼 외 스코프마다 컬럼 증식 | 기각 |
| | (C) 하이브리드(전역키 + 스코프 별도) | 유연 | 두 모델 동기·우선순위 복잡, over-engineering | 기각 |
| 게이트 축 | (A) JamRoleGate 별도 게이트(jamId 인자) | 전역 PermissionGate 무변경, 리소스 스코프 명확 | 게이트 클래스 1개 | **채택(J2)** |
| | (B) PermissionGate.has 에 resourceId 인자 추가 | 단일 게이트 | 기존 2인자 호출지점 전수 정정(W1 회귀), 전역 키와 잼 역할 의미 혼재 | 기각 |
| 잼 역할 캐시 | (A) 요청당 jam_judges 직접 조회(캐시 0) | 지정/해제 즉시 반영, epoch 불요, 단순 | 요청당 EXISTS 1회(인덱스 — 저비용) | **채택** |
| | (B) 세션 캐시 + epoch(전역 권한처럼) | 조회 절감 | 잼 역할용 별도 epoch·세션 attr 증식, 다중잼 캐시 복잡 | 기각(over-engineering) |
| 충돌 enforce 시점 | (A) 점수입력 시점(W2-4) 게임단위 거부 | 자기작품만 거부·타작품 심사 허용(유연), 지정 자유 | 시점이 지정과 분리 | **채택(J4/J7)** |
| | (B) 지정 시점에 출품자 제외 | 단순 | 지정 후 출품/지정 전 출품 타이밍 갭, 타작품 심사도 봉쇄(과도) | 기각 |
| 해제 저장 | (A) hard DELETE | 행 존재=현재 심사위원(isJudge 단순), 멱등 | 해제 이력 미보존 | **채택(J5)** |
| | (B) soft delete(is_delete) | 해제 이력 | isJudge 마다 필터·partial UNIQUE 복잡, 이력은 비목표 | 기각 |
| 지정 enforcement | (A) W2-1 exclude + 컨트롤러 게이트 헬퍼 재사용 | W2-1 선례 일치, InterceptorConfig 무수정(충돌 0) | W2-1 배포 의존 | **채택(J3-A)** |
| | (B) 별도 인터셉터 경로 매핑 | 중앙집중 | 경로↔키 테이블 신설(W1/W2-1서 기각된 방향), 중복 | 기각 |
| | (C) 임시 role 직접체크 | 빠름 | W1 인프라 우회·정석 위반(금지) | 기각 |

---

## 롤아웃 / 마이그레이션

### 순서
1. **스키마 적용**: `docs/jam-judge-ddl.sql` → `db/apply-local-ddl.sh`(로컬). 운영은 동일 멱등 DDL 수동 적용.
   - **선행 의존**: jam_judges FK 가 `jams`(W2-1 docs/jam-ddl.sql) + `users`(기존) 참조 → **jam-ddl.sql 이 먼저 적용돼야 함**. apply-local-ddl.sh 알파벳 글롭: `jam-ddl.sql` < `jam-judge-ddl.sql`(공통 `jam-` 뒤 `d` < `j`) → 순서 자동 보장. jam-eval-ddl(W2-3, `jam-e...`)과도 무충돌(jam_judges 는 eval 테이블 미참조).
2. **코드 배포(W2-1 이후)**: K-DOMAIN → K-MAPPER → {K-GATE, K-ADMIN}. **W2-1 의 InterceptorConfig exclude(`/admin/jams/**`) 가 선배포**돼야 SUBADMIN+GAME_JAM_MANAGE 가 `/admin/jams/{id}/judges` 도달(concern 5). W2-1 미배포 시 인터셉터 isAdmin 에 막힘 → W2-1 과 같은 배포 사이클 또는 그 이후.
3. **권한 시드 불필요**: GAME_JAM_MANAGE 키는 W1 PermissionCatalogVerifier 가 이미 시드(grounding R-A). 본 설계는 잼 스코프 역할 enforcement 추가만.
4. **운영**: 잼 관리자(ADMIN 또는 SUBADMIN+GAME_JAM_MANAGE)가 잼별 심사위원 지정 → W2-4 점수입력 게이트가 즉시 소비(jam_judges 직접 조회, epoch 불요).

### 역호환
- 전역 RBAC(user_permissions/PermissionGate)·jams/jam_entries/games **전부 무변경**. W1/W2-1 동작 0 영향.
- jam_judges 는 신규 테이블(추가 전용, 비파괴). 기존 사용자 영향 0.
- JamEntriesMapper.isOwnEntry 는 신규 메서드(기존 메서드 보존). admin-jam-list.jsp 는 섹션 추가(기존 폼 보존).

### 롤백
- 코드 롤백: JamJudgeAdminController/JamRoleGate/JamJudgesMapper/isOwnEntry 되돌리면 심사위원 지정·점수입력 게이트 소비 불가(W2-4 가 isJudge 의존 시 W2-4 도 영향 — 같은 사이클 롤백 고려). 신규 테이블은 추가 전용이라 잔존 무해.
- 스키마 롤백: jam_judges 는 추가 전용 → drop 없이 잔존 무해(비파괴). 명시 DROP 은 별도 maintenance(`DROP TABLE IF EXISTS jam_judges CASCADE;`).

---

## AC 매핑

| AC | 요구(골자 W2-2) | 만족 설계 요소 | 비고 |
|---|---|---|---|
| AC-1 | 잼 회차별 심사위원 역할(전역 RBAC 불변) | jam_judges 별도 테이블 + user_permissions 무변경 | J1, §전역무변경 |
| AC-2 | 잼 스코프 게이트 isJudge(session, jamId) | JamRoleGate.isJudge — jam_judges exists 조회 | J2, S3 |
| AC-3 | 심사위원 지정 = GAME_JAM_MANAGE 게이트 | JamJudgeAdminController requireJamManage(gate.has GAME_JAM_MANAGE) | J3, J3-A |
| AC-4 | SUBADMIN+키 지정 통과 / 무키 403 | gate.has(ADMIN OR SUBADMIN+GAME_JAM_MANAGE), W2-1 exclude | J3-A, S1 |
| AC-5 | 누구나 지정 가능(USER 포함) | 지정 시 대상 전역 role 검사 없음(실재만 404 체크) | J6, §외부계약 |
| AC-6 | 자기출품 충돌(점수입력 시 자기작품 거부) | JamRoleGate.isOwnEntry → W2-4 422. 개인+팀멤버 OR | J4/J7, S2, §isOwnEntry SQL |
| AC-7 | 역할 수명 = 잼 종료 후 잔존 | jam_judges 만료 회수 없음, soft delete 미채택(행 존재=현재). 점수입력 활성=W2-3 게이트 | J5 |
| AC-8 | 지정/해제 상태변경 전수 CSRF | assignJudge/removeJudge CsrfTokens.isValid 선검증 → 403 | §외부계약 공통 |
| AC-9 | 권한 SQL `${}` 0 | JamJudgesMapper + isOwnEntry `#{}` only | §파일영향맵/isOwnEntry SQL |
| AC-10 | 중복 지정 방지(멱등) | ux_jam_judges_jam_user UNIQUE + exists 409 | §데이터모델1, S1 |
| AC-11 | 잼별 심사위원 목록 조회 | listByJam(JOIN users) GET API | G7, §외부계약 |

---

## 검증 포인트 (verification-advisor 점검 대상)

> L레벨 매핑(verification-strategies): 인가/게이트/잼 스코프 역할 플로우 = **L1+L2+L3**. 신규 매퍼 SQL/alias·isOwnEntry OR-EXISTS = **L1+L2(dev DB contract)**. 신규 컨트롤러·매퍼·게이트(@Component) 의존 = full `./mvnw -o test` 의무(§30).

### 시나리오 검증
- **VP-1 (AC-3/4 지정 게이트, L1+L3)**: JamJudgeAdminControllerTest — ADMIN 통과 / SUBADMIN+GAME_JAM_MANAGE 통과 / SUBADMIN 무키 403 / 미인증 401(redirect). L3 스모크: W2-1 exclude 후 SUBADMIN 이 `/admin/jams/{id}/judges` 도달.
- **VP-2 (AC-2 isJudge, L1)**: JamRoleGateTest — 지정된 유저 true / 미지정 false / 미인증 false. jam_judges exists 조회 정확.
- **VP-3 (★AC-6 자기출품 충돌, L1+L2)**: JamRoleGateTest.isOwnEntry — ① 개인 출품(entrant_user_id==judge) true ② 팀 출품(judge 가 그 팀 멤버) true ③ 타인 출품/타팀 출품 false ④ 비활성(is_delete) 출품 false. dev DB contract: OR-EXISTS(개인 OR 팀멤버) SQL 실측(jam_entries/jam_team_members 샘플).
- **VP-4 (AC-5 누구나 지정, L1)**: USER role 대상 지정 200(전역 role 검사 없음). 미존재 userId 404.
- **VP-5 (AC-8 CSRF, L1)**: 지정/해제 CSRF 누락 → 403 + mapper 미호출(deleteCommentRejectsMissingCsrfBeforeMapperAccess 패턴 준용).
- **VP-6 (AC-10 멱등, L1+L2)**: 이미 지정된 (jamId,userId) 재지정 → 409(exists) 또는 UNIQUE 거부(catch→409). dev DB: ux_jam_judges_jam_user 중복 INSERT 거부 실측.
- **VP-7 (DB-방언 계약, L2)**: JamJudgesMapper 반환 POJO 키 == 컨트롤러/JSP 조회 키(snake→camel 직접 alias, jam_judges 는 일반매퍼 → **큰따옴표 alias 금지** 확인). isOwnEntry boolean 매핑(EXISTS) 정합.
- **VP-8 (contextLoads, L1)**: BibimbapApplicationTests 에 JamJudgesMapper + JamRoleGate @MockBean 등록 후 PASS(§30). 누락 시 NoSuchBeanDefinitionException.

### 집합 전수 체크 AC (집합 전수 패턴 — 시점·표현 self-audit 적용)
> self-audit(시점): 아래 카운트는 **본 W2-2 가 신규 생성하는 정적 산출물**(jam_judges DDL·제약·매퍼 메서드·관리자 액션)이며 verification 시점까지 본 워크스트림 외 변경 주체 없음(시점 안정). 자기 트리처럼 증가하는 대상 아님. JamEntriesMapper.isOwnEntry 추가분은 W2-1 매퍼에 들어가지만 메서드 1건 추가라 카운트 안정.
> self-audit(표현): 단일 리터럴 grep 취약성을 피해 제약명/액션 핸들러/충돌 OR-분기 같은 **구조적 불변식**에 앵커. 매퍼 `${` 0건은 부재 검증이라 리터럴 정당.

- **AC-T1 jam_judges 무결성 제약 전수 4건 존재** — docs/jam-judge-ddl.sql 의 제약 전수: FK 3종(jam_id→jams / user_id→users / assigned_by→users) + UNIQUE 1종(ux_jam_judges_jam_user): `grep -c 'ADD CONSTRAINT' docs/jam-judge-ddl.sql` == 3(FK) AND `grep -c 'CREATE UNIQUE INDEX' docs/jam-judge-ddl.sql` == 1. AND db/schema.sql 에 jam_judges 동일 제약 전수 존재(동기 사본 누락 검출). 제약 추가/삭제 누락을 갯수로 동시 커버.
- **AC-T2 관리자 심사위원 액션 전수 3건 게이트** — JamJudgeAdminController 의 핸들러(지정/해제/조회) 전수가 `requireJamManage`(또는 gate.has(GAME_JAM_MANAGE)) 호출: 게이트 헬퍼 호출 수 == 핸들러 수(상태변경 핸들러 지정/해제는 추가로 CsrfTokens.isValid). 핸들러 추가 시 게이트 누락 = 인가 우회 보안결함 → FAIL. **이 전수 AC 가 J3 지정 enforcement 의 핵심 가드**(수동 판정: @PostMapping/@GetMapping 핸들러 열거 후 각 진입부 게이트 확인 — 리터럴 grep 단독 의존 회피).
- **AC-T3 상태변경 액션 전수 CSRF 가드** — JamJudgeAdminController 상태변경 핸들러(지정/해제 2건, GET 조회 제외)에 `CsrfTokens.isValid` 선검증 존재: `grep -c 'CsrfTokens.isValid' JamJudgeAdminController.java` == 상태변경 핸들러 수(2). 핸들러 추가 시 CSRF 누락 동시 검출(AC-8).
- **AC-T4 자기출품 충돌 양경로(개인+팀) 전수** — JamEntriesMapper.isOwnEntry SQL 이 entrant_type 양경로 전수 커버: 'USER'(entrant_user_id) 분기 AND 'TEAM'(jam_team_members EXISTS) 분기 둘 다 존재(OR 결합). 검증: SQL 에 `entrant_type = 'USER'` AND `entrant_type = 'TEAM'` 두 분기 grep 매치 각 1 AND L2 실측(개인/팀 출품 각각 충돌 true, 타인 false). **1경로 누락 = 충돌 우회(팀 출품 심사위원이 자기 팀 작품 채점) 보안결함 → FAIL**. W2-1 jam_entries XOR 구조(entrant_type 2값) 전수 대응 불변식.
- **AC-T5 jam_judges 매퍼 `${` 0건** — JamJudgesMapper + JamEntriesMapper(isOwnEntry 추가분)에 `${` 매치 0: `grep -rc '\${' JamJudgesMapper.java JamEntriesMapper.java` == 0 (AC-9, `${}` 동적치환 금지). 부재 검증이라 리터럴 정당.
- **AC-T6 신규 빈 @MockBean 전수 등록** — BibimbapApplicationTests 에 JamJudgesMapper + JamRoleGate 전수 @MockBean 등록: contextLoads PASS AND 두 빈 등록 확인(JamRoleGate 가 JamJudgesMapper/JamEntriesMapper 의존 @Component 이므로 의존 매퍼 MockBean 도 필요). 1건 누락 시 contextLoads NoSuchBeanDefinitionException 로 즉시 검출(§30, verification 시점 자기 검증).
- **AC-T7 전역 RBAC 무변경 불변식** — user_permissions/permissions/users 가 본 동결로 인해 변경 0: docs/jam-judge-ddl.sql 에 `user_permissions`/`permissions`/`users` 테이블의 ALTER/CREATE 변경문 0건(jam_judges FK 가 users 참조하는 `REFERENCES "users"` 는 무방하나, users 테이블 ALTER/CREATE 는 0). 검증: `grep -E 'ALTER TABLE "(user_permissions|permissions|users)"|CREATE TABLE.*"(user_permissions|permissions)"' docs/jam-judge-ddl.sql` 0건. **★보안 단언(J1 전역모델 보호) 위반 즉시 검출**.

---

## 잔여 오픈 질문
없음(0). 확정 결정 J1~J7 전제 고정. 두 난제(스코프 게이트 축 분리·자기출품 충돌 개인+팀 커버)는 본 설계가 구체 메커니즘으로 확정. 인터셉터 무수정(W2-1 exclude 재사용, J3-A)·해제 hard DELETE(J5)·충돌 enforce 시점 점수입력(J7)도 확정. 구현 점검 항목(시그니처 inflate·신규 매퍼/게이트 @MockBean full-test·DB-방언 L2·W2-1 JamEntriesMapper/admin-jam-list.jsp 소유권 경계·W2-1 배포 순서 의존·충돌 enforce W2-4 소비 재확인)은 오픈 질문이 아니라 `concerns`/`crossRefs` 로 이관.
