bibimbap/.atp/work-session/20260623-104307/implementation/W2-2-judge-role-design.md

44 KiB

phase agent agent_version generated_at workstream concerns concerns_checked self_verification references
design design-advisor 1 2026-06-23T16:00:00+09:00 W2-2-심사위원 역할 권한(잼 스코프 게이트)
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)이다. 따라서 자기 출품작이 있는 유저도 심사위원으로 지정될 수 있고(다른 출품작은 심사 가능), 자기 출품작에 대해서만 점수입력이 거부된다. 지정 자체를 막지 않는 이유는 §충돌 규칙 계약에 명시 — 구현이 지정 시점에 막지 않도록 주의.
true
checklist_passed
true
requirements research adrs
docs/work-log/2026-06-23-w2-w4-feature-skeletons.md .atp/work-session/20260623-104307/research/W2-W4-grounding.md
.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-ddl) < j(jam-judge) → jam-ddl 먼저 적용 보장(롤아웃 §순서 재확인). game-reviews-ddl(g)·rbac-ddl(r)과도 무충돌.

신규 파일: docs/jam-judge-ddl.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 방지)

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

-- 본인 출품작 충돌 판정: 활성 출품작 (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.sqldb/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 로 이관.