--- 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>`(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 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` 로 이관.