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-심사위원 역할 권한(잼 스코프 게이트) |
|
true |
|
|
설계: 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조회로 잼별 심사위원 자격 판정. W1PermissionGate(전역 키 판정)와 별도 축. 리소스(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, DOguard, CREATE UNIQUE INDEX IF NOT EXISTS. 타입은 기존 스타일(bigint/varchar/timestamptz).⚠️ 알파벳 글롭 순서:
apply-local-ddl.sh가 docs/*-ddl.sql 을 알파벳순 적용.jam_judgesFK 가jams(W2-1 docs/jam-ddl.sql) +users(기존) 를 참조하므로 jam-ddl 이 jam-judge 보다 먼저 적용돼야 한다. 알파벳: 공통 prefixjam-뒤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 의requireJamManageprivate 헬퍼와 동일 패턴(재사용 권장 — 중복 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 헬퍼 재사용 또는 동형):gate.isAuthenticated(session)거짓 → 페이지면 redirect:/login, API 면 401.gate.has(session, PermissionKeys.GAME_JAM_MANAGE.name())거짓 → 403.- 통과 후 본문.
- 이유: 심사위원 지정은 잼 관리 행위 → 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 인프라 우회·정석 위반(금지) | 기각 |
롤아웃 / 마이그레이션
순서
- 스키마 적용:
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 테이블 미참조).
- 선행 의존: jam_judges FK 가
- 코드 배포(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 과 같은 배포 사이클 또는 그 이후. - 권한 시드 불필요: GAME_JAM_MANAGE 키는 W1 PermissionCatalogVerifier 가 이미 시드(grounding R-A). 본 설계는 잼 스코프 역할 enforcement 추가만.
- 운영: 잼 관리자(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) ANDgrep -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'ANDentrant_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.sql0건. ★보안 단언(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 로 이관.