# 관리자 RBAC 마일스톤 9b (보완 계획 admin-rbac-m9b) 기록

## 마감 요약

- 판정: **보완 계획 admin-rbac-m9b 는 마감한다. 마일스톤 9 (PRD 행 9) 는 in-progress 를 유지한다.** 행 9 의 상태 열은 바꾸지 않았다. 계획 · 노트 · 백로그 · PRD 결정 로그만 갱신했다.
  - 이유: 행 9 증명의 「이행표 권한 소실 0」 을 측정하지 않았다. 코드 · 격리로 할 수 있는 항목이므로 complete 로 올리지 않았다. 정적 근거 (편차 3 · M4 적재 대조) 만 있다.
  - 사용자 몫만 남았다면 complete 였을 것이다. 「영역별 would-deny 판단 표」 · R2 · R7 · R19 · 스위치 ON 은 사용자 몫이다.
- 보완 계획 4항목 결과 (계획 「이번 보완 범위」):
  1. 확정 principal 전달 · `req.admin` 덮어쓰기 제거: 구현 완료. 강제 ON 에서만 확정하고 OFF 는 응답 불변이다 (앱 실부팅 probe `before_eq_after: yes`, 검사 #5 · #6 · #7 · #28 · #29 · #30).
  2. 교차 기업 · 역할 없는 신원 거부 음성: 격리 MySQL 실측 녹색 (검사 #8, 5건). 교차 기업 거부는 범위 상수가 정하고 영역 스위치는 관여하지 않는다 (편차 6).
  3. 칸 · 표 부재 시 최고관리자 판정: 평가기 순서를 고쳤다. 최고관리자 200 · 내부 관리자 403 E0607 을 격리 MySQL 가드 수준으로 실측했다 (검사 #9 · #10). 앱 전체 부팅 실측은 하지 않았다 (백로그 1919 MEDIUM).
  4. 시한 초과 KILL QUERY 취소 · 연결 회수: 격리 MySQL 실측 녹색 (시한 503 · 서버 질의가 PROCESSLIST 에서 사라짐 · 풀 회복 · 포화 시 취소 · 검사 #11 · #12 · #30). 동시 판정 상한 N 과 대기열 정책은 잔여다 (백로그 1894 · 1902, ON 체크리스트 18).
- 게이트 결과: 마감 재실행 `mccp-gatekit/m9b/gates-r6-final.out` (격리 MySQL `m9b-iso-` 새 컨테이너). 30검사 전부 PASS · `M9B_GATES_DONE=1 FAILS=0` · rc 0 (검사 #13 · #14 · #15 · #16 은 이번 마감의 백로그 행 1921 · 1922 를 포함해 통과).
  - 사이드카 봉인 `sha256` 은 `121b2697…` 로 같다. 검사 정의 변경 0.
- 코드 리뷰와 최종 리뷰:
  - 구현 뒤 코드 리뷰 R1 · R2 · R3 (상세는 「## 코드 리뷰」). 메인 판정으로 검사 #15 · #16 정의를 고쳤다 (「## 메인 판정」).
  - 최종 리뷰 R1: fable 3건 (LOW) · codex 1건 (MEDIUM). 흡수 3 · 기각 1 · HIGH 0 · CRITICAL 0 (「## 최종 리뷰」).
  - 최종 리뷰 라운드 요약: `[{"r":1,"found":4,"absorbed":3,"high_absorbed":0,"high_open":0,"codex":"ok"}]`
- PRD 미달 (행 9 in-progress 사유): 증명 「이행표 권한 소실 0」 을 측정하지 않았다. 권한원 이행표의 I · AO 행을 영역 2 · 3 임시 ON 빌드에서 신원별로 재생해야 한다. 백로그 HIGH 행 1921 · 짝 행 1922 → 보완 계획 `admin-rbac-m9bb`.
- (미검증) · 사용자 몫 (complete 를 막지 않는다. 정책 문서가 defer-unverified 로 둔 항목):
  - 영역별 would-deny 판단 표 실내용: 복제 환경 1회 재생 (실패 0건 또는 기대값 일치). 복제본 생성은 사람 승인이다.
  - R2 (계정 · 권한 쓰기 최고관리자 한정): block, 활성 금지.
  - R7 (`ath_id` NULL 내부 관리자 수): 운영 SELECT 승인 뒤 (「R7 NULL 탐지 절차」, m9 노트).
  - R19 (소비처 책임자 · IP 5 라우트): 가드는 유지한다. 소비자 대응표 (site · 앱 · 콜백 · 스케줄러) 정상 회귀는 사용자 확인이다.
  - 칸 부재 최고관리자 판정의 업무 판단: 현 구현은 칸 · 표 부재에서 최고관리자를 통과시킨다 (PRD 행 4 문언). 평가기 계약 주석 (`evaluate.ts:5-6`) 과 PRD 문언 대조는 최종 리뷰 · 사용자 몫이다.
  - 동시 판정 상한 N · 대기열 정책 · 부하에서 503 거짓 양성 비율: 부하 실측 뒤 (ON 체크리스트 18, 미검증).
  - KILL 이 실패하는 경로에서 서버 질의가 남는지 (미검증). 연결은 파기한다.
  - KILL 전용 연결의 권한이 복제 · 운영 계정 구성에서 허용되는지 (미검증, 복제 환경 1회 뒤).
  - 영역 상수 3개 · 범위 상수 · 메뉴 스위치 ON 커밋, 이미지 SHA 재배포: ON 체크리스트 전부 채운 뒤 사용자 몫.
  - 옛 번들 403 실배포 확인 (M7).
  - `access-control.constants.ts:29` 의 `ENFORCE_JUDGE_TIMEOUT_MS` 주석 「서버 쿼리는 취소하지 않는다」 는 낡았다 (허용 목록 밖, LOW). 다음 계획 Files 에 넣는다.
- 커밋: 이 마감은 부모 1건 (`docs(prd)`) 이다. 구현 커밋은 서버 `8fe118e9` (feat) · `3418d3a0` (ci), 부모 `e812d36` · `4a62d93` 다. 착수 기준은 서버 `92b5aa07`. 어드민 `1301b664` · 사이트 `fc8872a1` 은 바꾸지 않았다.
- 다음 마일스톤이 알아야 할 것:
  1. 행 9 가 in-progress 라 행 10 · 11 의 착수 조건 「M9 완료」 는 서지 않는다. 정책 §2 행 10 은 로컬 강제 모드 시험으로 개발만 허용한다. M9 실강제 완료를 대신하지 않는다.
  2. 보완 계획 `admin-rbac-m9bb` 의 입력: 이행표 재생 측정 (백로그 1921) · 칸 · 표 부재 앱 전체 부팅 (1919) · 영역 2 ON 앱 부팅 · 동시 판정 상한 N · 대기열 정책 (1894 · 1902) · 계약 주석 (`evaluate.ts:5-6`) · 위 `ENFORCE_JUDGE_TIMEOUT_MS` 주석.
  3. 검사 #13 은 「다음 계획 반영 대상」 과 `admin-rbac-m9` 이 같은 줄에 있는 HIGH 행마다 짝 행을 요구한다. `m9bb` 이름의 HIGH 행도 짝 행이 필요하다.
  4. 활성 (영역 상수 · 메뉴 스위치 ON) 은 사용자 몫이다. 상수를 켜서 게이트를 통과시키지 않는다.

계획 `.claude/PRPs/plans/admin-rbac-m9b.plan.md` · 검사 사이드카 `.claude/PRPs/plans/admin-rbac-m9b.gates.sh`.
이 노트는 구현 단계 기록이다. 커밋은 하지 않았다 (사용자 지시: 커밋 전 별도 세션에서 code-review).

## 착수 기록

- 착수 sha: 부모 `d2cf29b`, 서버 `92b5aa07`, 어드민 `1301b664`, 사이트 `fc8872a1` (계획 머리와 같다).
- 정책 기준 커밋 `ea3c0359` 는 서버 HEAD 의 조상이다 (검사 #1).
- 사용자 지시 원문 확인: `verify-user-directive.sh` exit 0 (세션 기록 type=user · userType=external, 검사 #24). 원문은 옮기지 않는다.
- plan receipt 부재: skip-receipt 지시 (사용자 지시 2026-10-19, 라운드 상한 3 의 마지막 라운드 규칙). `MCCP_SKIP_RECEIPT=1` 은 명령 단위로만 쓴다. 세션 · 설정에 두지 않는다.
- 게이트 라운드: `codex-policy seal` (codex_disabled=false) 와 `review-rounds seal` (cap=3, key `mccp-implement-codex__admin-rbac-m9b`) 을 Phase 2.5.0 에서 봉인했다.
- 로컬 산타 원장 `.claude/state/santa-ledger.md` 의 「마일스톤 9b」 구획 머리는 계획 작성 때 이미 있었다 (검사 #27).

## 구현 편차 (계획과 다르게 간 자리)

1. **enforce.spec.ts 를 고쳤다 (계획 Files to Change 밖).** 강제 ON 경로가 풀과 복원을 요구하므로 기존 harness (`new AccessControlGuard(jwt, {})`, 평가기 · 권한 어댑터 mock) 가 ENOPOOL 로 깨졌다. 기존 expect 줄은 한 줄도 바꾸지 않았다. harness 만 바꿨다 (코드 리뷰 R1: 이 파일의 ON 경로 시험은 실제 풀 · 실복원을 타지 않는다는 커버리지 감소를 여기 적는다. 실경로는 「구현 편차 5」 와 「격리 실부팅」 절이 본다): 판정 연결을 시한만 흉내 내는 mock (`judge-cancel` 대체), 복원을 payload 확정 mock (`admin-token-version` 의 `restoreAdminIdentity` 대체), 가드에 `{ pool: {} }` 를 넘김. 검사 #22 의 허용 목록 (서버 쪽) 에는 이 파일이 없다. 이 편차는 리뷰 판정 대상이다.
2. **judge-cancel 의 KILL 연결 설정.** `createConnection(pool.config.connectionConfig)` 를 통째로 넘기면 mysql2 가 내부 칸을 옵션으로 보고 경고를 낸다. 그래서 host · port · user · password · database · charset 만 옮긴다. 연결 설정은 그대로이고 경고만 없어졌다.
3. **judge-cancel 의 거절 시점.** 시한 초과 거절은 시한 시점에 낸다. KILL 과 정리는 뒤에서 한다 (`void cleanupAfterDeadline`). itspec 은 PROCESSLIST 를 폴링해서 취소를 본다.
4. **prettier.** 바뀐 서버 파일 17개 중 12개는 HEAD 자체가 prettier 기준이 아니었다 (`git show HEAD:<f> | prettier` 가 다름). 검사 #26 과 계획 Task 9 (b) 에 따라 `prettier --write` 를 전체 바뀐 파일에 돌렸다. 포맷 전용 diff 가 섞였다 (코드 리뷰 R1 실측: 바뀐 서버 `.ts` 19개 = 기존 파일 10 + 새 파일 9. HEAD 가 prettier 기준이 아닌 파일은 2개 (`access-control.guard.spec.ts` 포맷 차이 40 줄, `access-identity.ts` 15 줄). 서버 diff 는 기존 파일 기준 563 줄 추가 · 76 줄 삭제). 커밋 단계에서 이 포맷 전용 변경은 `style(...)` 커밋으로 분리한다 (리뷰 L13 의 제안, 커밋은 이번 라운드에 하지 않는다). 메모 `server-auth-files-not-prettier-clean` 의 「--write 금지」 와 충돌한다. 계획을 따랐고, 이 편차는 리뷰가 본다.
5. **격리 실부팅 대신 가드 · 평가기 · 실제 DB 연결 (itspec 의 범위).** `evaluate-absent.itspec.ts` 는 앱 전체를 부팅하지 않는다 (코드 리뷰 R1 H4 로 주석을 정정했다). 앱 전체 부팅 실측은 「## 격리 실부팅 (코드 리뷰 R1)」 절의 probe 가 본다. 단 그 probe 는 영역 1 을 켰고 칸 · 표 이름은 바꾸지 않았다. 그래서 칸 · 표 부재를 앱 배선으로 본 실측은 없다 (코드 리뷰 R3 MEDIUM). `AccessControlGuard` 를 실제 풀 · 실제 평가기 · 실제 권한 어댑터에 붙이고, 칸 `tn_admin.ath_id` 와 표 `tn_auth_permission` 의 이름만 바꾼다 (afterAll 에서 되돌린다). 최고관리자 통과 · 내부 관리자 E0607 을 본다. 전체 앱 기동 실측은 하지 않았다. 검사 #10 주석과 Acceptance 의 「격리 실부팅」 은 이 가드 수준 실측을 가리킨다 (계획 동결로 문구는 그대로 두었다, 백로그 MEDIUM 행).
6. **교차 기업 음성은 컨트롤러 범위 판정으로 본다.** `enforce-cross-tenant.itspec.ts` 는 `MoneyAdminController.getIssueDetail` 을 scope-single-join 과 같은 배선으로 부른다 (기업 9002 신원 → 기업 9001 지급 = 거부, 기업 9001 = 허용). 역할 없는 신원 (ath 3, 권한 행 없음) 과 NULL 신원은 가드 · 평가기로 E0607 을 본다. 정상 경로는 ath 2 (권한 행 있음) 가 통과한다. 교차 기업 거부의 실제 응답 (R3 정정): 범위 상수가 켜진 상태에서 `assertSingleOwnedAgc` (`money-admin.controller.ts:163`) 가 403 · 본문 `{ code: 'E0607', reason: 'scope', message: '담당 범위 밖의 머니 내역은 조회할 수 없습니다' }` 을 낸다. 즉 이 거부는 범위 상수가 정하고 영역 스위치는 관여하지 않는다. 기업 소속 판정 (`money-admin.controller.ts:174`, 「다른 기업의 지급 내역…」) 은 이 시험에서 닿지 않는다.
7. **m6c jest-itspec 설정의 drizzle 스텁.** 기본 jest 설정은 `drizzle/` 를 스텁으로 바꾼다. m6c 설정 (검사 사이드카가 쓰는 `ITCFG`) 은 그 매퍼를 그대로 물려받으므로 스키마 표가 빈 객체로 나온다. 세 개의 itspec (`judge-cancel` · `enforce-cross-tenant` · `evaluate-absent`) 은 파일 안에서 `jest.mock('drizzle/schema', () => jest.requireActual('../../../../../../drizzle/schema'))` 로 실제 스키마를 잇는다. 설정 파일 자체는 건드리지 않았다.
8. **probe 의 before 행 파일.** 게이트 #7 은 after 빌드만 돌린다. 그래서 before 행을 `.claude/meta/2026-10-23-admin-rbac-m9b/rows-before-m9b.txt` 로 한 번 떠서 두었고, `isolated-probe.sh` 가 after 실행 끝에 cmp 한 요약 (`before_eq_after: yes`) 을 쓴다. `identities-scope.json` 은 m6d 에서 사본을 가져왔다.
10. **익명 · 열람 토큰 요청은 풀을 잡지 않는다 (Codex R1 · R2 흡수).** 강제 ON 에서 판정 대상이 아닌 요청 (쿠키 없음 · 무효 쿠키 · 회원 · 외부 Basic) 은 `enforce()` 가 판정 연결을 열지 않고 바로 넘긴다 (현행과 같이 DB 접근 0회). 열람 토큰 헤더만 있는 요청은 DB 를 쓰지 않는 합성 판정이므로 풀을 잡지 않는다. 회귀 시험: 「익명 요청은 스위치가 켜져도 풀을 쓰지 않는다」.
11. **KILL 전용 연결의 시한 초과.** `killQuery` 가 시한을 넘으면 전용 연결을 파기하고 다음 취소에서 다시 연다 (`resetKillConnection`). R3 HIGH (1906) 가 지적한 정체 경로는 이 재생성으로 막는다. 코드 리뷰 R1 에서 이 주장을 시험으로 증명했다 (`judge-cancel.spec.ts` 「KILL 응답이 멈추면 …(1906)」, 반사실로 reset 을 빼면 붉음). 연결 파기는 맵 항목과 무관하게 하고, 맵 삭제는 실패한 연결과 같을 때만 한다 (ABA 흡수). 백로그 짝 행은 코드 리뷰 R1 에서 1906 · 1907 두 개를 RESOLVED-IN 으로 썼다 (아래 「## 코드 리뷰」).
12. **implement receipt 쓰기.** `receipt write` 는 env 없이 통과했다 (plan 게이트 receipt 부재로 거부되지 않았다). 그래서 `--skip-reason` 과 `MCCP_SKIP_RECEIPT` 는 쓰지 않았다. validate 는 `missing: mccp-plan-codex` 로 exit 2 다 (아래). 이 receipt 는 converged 로 찍혔고 skipped 가 아니다.

13. **전역 가드의 복원 실패 닫기 (코드 리뷰 R1 · R2 · R3 정정).** 계획 (잠정 결정 행 「확정 principal」, Risks 10, 「정책과 다르게 옮긴 자리」 4) 은 전역 가드가 복원 null · error 를 판정 없이 넘기고 뒤 가드가 닫는다고 썼다. 구현은 전역 가드가 자기에서 닫는다 (`access-control.guard.ts` `unrestoredError`): 복원 null 은 401, 판정 불가 (권한 버전 판정 불가 · 그 밖의 오류) 는 503. 근거는 PRD 행 4 「DB 오류를 전권으로 대체하지 않는다」 (fail-closed) 다 (백로그 1907 흡수). 계획 Risks 10 의 「뒤따르는 가드가 없는 라우트는 익명」 문장은 계획 동결로 그대로 두었다. 이 항목이 그 문장을 대신한다.

9. **summary 의 after_sha.** 이 probe 는 `git rev-parse HEAD` (부모 기준 서버 HEAD) 를 after_sha 로 적는다. 워킹트리는 커밋하지 않았으므로 after_sha 는 착수 sha 와 같게 나온다. 행 내용 (본문) 은 워킹트리 빌드다.

## 소비처 전수

기준 커밋 `92b5aa07` 의 `git grep` 과 워킹트리 `grep -r` 을 같은 정규식 `(req|request)(\.admin\b|\['admin'\])` 로 비교했다 (검사 #4).
- 기준 줄 수 152, 워킹트리 줄 수 155 (추가 · 제거 줄은 jwt · optional 가드 안에서만 바뀜).
- 허용 파일 (`jwt.guard.ts`, `admin-optional.guard.ts`) 밖의 차집합은 0 이다 (검사 #4 결과는 아래 「구현 뒤 실행 결과」).
- 대표 소비처 셋 (`money-admin.controller.ts` · `fc-agency.service.ts` · `medication-standard-public.controller.ts`) 이 읽는 칸은 role · corpScope · agcId · admId 다. `memId` 는 관리자 토큰 형상에 없고 (`req.admin.memId ?? null` 은 관리자 요청에서 늘 null), 현행과 같다. 시험은 `consumers-same-value.spec.ts`.

## 판정 불가

- `evaluate.ts` 를 바꿨다. 표 · 칸 부재 (`table-absent` · `column-absent`) 는 즉시 unknown 으로 돌려주지 않고 `pending` 에 든 채 (6) 최고관리자 판정을 읽는다.
  - 최고관리자면 `allow` (`super`, superChecked).
  - 아니면 최고관리자 전용 코드는 `super-only` 거부. 나머지는 `pending` 사유의 unknown (superChecked). 사유 표 `UNKNOWN_OUTCOME` 이 거부로 접는다 (표 · 칸 부재는 여전히 거부다).
  - 행 조회가 부재로 실패하면 athId 를 모르므로 `no-binding` 거부를 내지 않는다.
  - db-error 는 현행대로 즉시 unknown (halt). super-unknown 도 현행대로 halt.
- 이 규칙은 PRD 행 4 문언에 따른 메인 세션 판정이다. 평가기 계약 주석 (`evaluate.ts:5-6`, 「allow 만 허용이다」) 과 충돌하지 않는다. 판정 불가를 허용으로 바꾸지 않고, 최고관리자의 `allow` 만 더했다. 계약 주석 정정은 하지 않았다 (최종 리뷰와 사용자 몫).
- 사용자 확인 항목: 칸 · 표 부재에서 최고관리자 통과의 업무 판단 (정책 · PRD 문언 대조).

## 시한 취소

- `judge-cancel.ts` 의 `runJudgeOnConnection(pool, work, ms)`:
  - 공용 풀에서 연결을 받는다 (받는 시간도 시한에 든다). 시한 뒤 도착한 연결은 즉시 release.
  - `SELECT CONNECTION_ID()` 로 번호를 얻고, 정수가 아니면 KILL 문을 만들지 않는다 (`Number.isSafeInteger`).
  - 작업은 그 연결에 묶인 drizzle 핸들로 돈다. 시한을 넘으면 ETIMEDOUT 으로 거절 (호출자는 503).
  - 뒤에서: 풀 밖 전용 KILL 연결 (풀마다 지연 생성, `KILL_TIMEOUT_MS` 500 ms) 로 `KILL QUERY <id>`. 작업이 끝났고 KILL 이 성공하면 release, 아니면 destroy.
- 실측 (격리 MySQL 8.0.42, `judge-cancel.itspec.ts`, 3건 녹색): 시한 초과에서 SLEEP 이 PROCESSLIST 에서 사라지고 503 이다. 풀 크기 1 에서 다음 판정이 정상이다 (풀 회복). 풀 크기 2 에서 A 가 점유하고 판정 B 의 질의만 취소된다 (포화).
- 잔여 (범위 밖, 백로그 1894 · 1885): 판정 동시 상한 N 과 대기열 정책은 없다 (`queueLimit 0`). 풀 연결 수 상한은 판정 동시 수 상한이 아니다. KILL 전용 연결은 인스턴스마다 하나 더 든다 (`max_connections` 예산, Risks 8). 부하 실측과 묶어 ON 전에 한다.
- 미검증: KILL 이 실패하는 경로에서 서버 질의가 남는지는 측정하지 않았다. 연결은 파기한다. (Acceptance (미검증))

## ON 체크리스트

정책 §4 항목 가운데 이번 보완으로 닫힌 것과 남은 것.
- 항목 11 (확정 principal 전달): 구현 완료. 활성은 스위치 ON 과 함께 한다 (정책과 다르게 옮긴 자리 1). OFF 응답 불변은 probe 로 봤다 (`before_eq_after: yes`).
- 항목 18 (동시 상한과 대기열): 잔여 (위 「시한 취소」).
- 판정 불가 (칸 · 표 부재 최고관리자 통과): 사용자 확인 대상.
- `evaluate.ts:5-6` 계약 주석 정정: 최종 리뷰와 사용자가 정한다.
- 부하 실측 (거짓 503 비율, 풀 대기가 시한을 넘는 비율, KILL 전용 연결 수): 사용자 몫 (미검증).
- 복제 환경 1회 실행 (운영 쪽 완료 조건, 실패 0건): 사용자 몫 (UI7).
- 상수 전부 OFF 유지: 영역 상수 3개 · `SCOPE_ENFORCE_SALES_CORP` · `MENU_SOURCE_DERIVED`.

## 영역별 would-deny 판단 표 (빈 양식)

정책 §4 의 열 머리 그대로. 실내용 없음 (미검증). 복제 환경 1회 재생 뒤 사용자가 채운다.

주의 (최종 리뷰 R1 · 감시 분류): 표 · 칸 부재 창에서 감시 분류가 착수 전 기록과 다르다. 최고관리자의 `unknown|table-absent` 는 `allow|super` 로, 내부 관리자의 super-only 코드는 `unknown` 에서 `deny|super-only` 로 센다. 이 창의 행을 판단 표에 올릴 때 이 차이를 먼저 본다 (「## 최종 리뷰」).

| 영역 | 경로 | would-deny 건수 | 판단 | 책임자 |
|---|---|---|---|---|
|  |  |  |  |  |

## 이월 판정

계획 「이월 지적 반영」 표 106건은 백로그의 `다음 계획 반영 대상` 표지 집합을 덮는다 (검사 #15). 짝 행 6개는 계획 커밋에서 append 했다 (`[원 1882] [원 1894] PARTIAL [원 1895] [원 1896] [원 1898] [원 1899]`). 짝 인용 실재는 검사 #14.
- 1805 (PG 취소 조정 로그 없음): 해소 아님. 영역 3 ON 전 사용자 판단 입력으로 남긴다.
- 1484, 1560 · 1563 · 1564 · 1577, 1652, 1657, 1684, 1687: 이연. 근거와 소관은 계획 표를 본다.

## 구현 뒤 실행 결과

게이트 사이드카 (`bash .claude/PRPs/plans/admin-rbac-m9b.gates.sh`, 세 번째 실행, 마지막 코드 변경 뒤) 결과: **26 통과 · 4 실패** (검사 #13 · #15 · #16 · #22).

| 검사 | 결과 | 근거 |
|---|---|---|
| #1 | PASS | `ANCESTOR_OK=13` |
| #2 | PASS | 정책 마일스톤 9 행 7 · 계획 7 · PRD complete 3 |
| #3 | PASS | `AREA_OFF=3 ON_LITERAL=0 SCOPE_OFF=1 MENU_OFF=1` |
| #4 | PASS | `CENSUS_OUTSIDE_ALLOW=0` (기준 152 줄) |
| #5 | PASS | principal 시험 4건 녹색, 제목 토큰 「확정」 「복원」 「덮어쓰지」 |
| #6 | PASS | access-control 묶음 190건 녹색, `GUARD_SPEC_EXPECT_REMOVED=0` |
| #7 | PASS | probe `before_eq_after: yes` (6353 줄 본문 cmp, 스위치 OFF) |
| #8 | PASS | 격리 MySQL 교차 기업 시험 5건 녹색 (제목 토큰 3종) |
| #9 | PASS | evaluate 시험 23건 녹색 (제목 토큰 7종) |
| #10 | PASS | 격리 칸 · 표 부재 시험 2건 녹색 (최고관리자 200 · 내부 관리자 403) |
| #11 | PASS | judge-cancel 단위 시험 6건 녹색 |
| #12 | PASS | 격리 시한 취소 시험 3건 녹색 (시한 초과 · 풀 회복 · 포화) |
| #13 | **FAIL** | `UNPAIRED_M9_HIGH=[1906 1907]` (아래 「검사 #13 · #15 · #16」) |
| #14 | PASS | 짝 행 6 · 인용 누락 0 · 허상 0 |
| #15 | **FAIL** | `MISSING=[1906 1907]` (같은 원인) |
| #16 | **FAIL** | `BACKLOG_DEL=0 BACKLOG_ADD=15 PAIR_ROWS=6` (추가 15 ≠ 짝 6, 같은 원인) |
| #17 | PASS | 회귀 묶음 637건 녹색 |
| #18 | PASS | tsc `TSC_BASE=23 TSC_NOW=23` |
| #19 | PASS | notes 필수 절 8개 전부 |
| #20 | PASS | PRD 결정 로그 절 1 |
| #21 | PASS | CI 네 줄 1 · 1 · 1 · 1 |
| #22 | **FAIL** | `OUT_OF_SCOPE insuniverse-server/apps/api/src/modules/auth/access-control/enforce.spec.ts` (편차 1) |
| #23 | PASS | `HOST_PATHS=0 CONN_CONFIG_OUT=0` |
| #24 | PASS | 사용자 지시 원문 확인 exit 0 |
| #25 | PASS | plan-kit lint rc 0 |
| #26 | PASS | 바뀐 서버 `.ts` 19개 prettier 통과 (편차 4) |
| #27 | PASS | 산타 원장 9b 구획 머리 1 |
| #28 | PASS | 확정 가드 시험 7건 녹색 (제목 토큰 6종) |
| #29 | PASS | 소비처 같은 값 시험 2건 녹색 |
| #30 | PASS | 가드 ON 경로 시험 17건 녹색 (제목 토큰 6종, 익명 · 풀 회피 포함) |

### 검사 #13 · #15 · #16 (백로그 행 추가 시점)

백로그에 `2026-10-23 | HIGH | .claude/PRPs/plans/admin-rbac-m9b.plan.md` 행 두 개 (줄 1906 · 1907) 가 있다. 계획 게이트의 마지막 라운드 (R3, 커밋 `bc953ab`, 「계획 게이트 상한 처리를 기록한다」) 가 계획을 동결한 뒤 추가했다. 계획의 「이월 지적 반영」 표는 그 전에 만들어졌으므로 두 번호가 없다. 그래서 검사 #13 (짝 없는 HIGH) · #15 (표 집합) 가 붉고, 검사 #16 의 추가 줄 수가 짝 행 수 (6) 와 다르다 (15). 짝 행을 억지로 달면 「해결」 을 거짓으로 주장하게 되므로 쓰지 않았다. 두 행의 내용은 다음과 같다. (1) R3 L3 codex: KILL 시한 초과 시 전용 연결을 파기하지 않으면 취소가 정체된다 — 구현은 `resetKillConnection` 으로 파기 · 재생성하므로 이 경로는 막혀 있다. 짝 행은 다음 계획이 쓴다. (2) R3 L2 invariant: 전역 ON 가드가 복원 기록 null · error 면 판정 없이 넘긴다 (fail-open 으로 읽힌다). 뒤따르는 관리 가드가 매핑 라우트에 있는지 대조하는 검사가 없다. 이 행은 계획 Risks 10 의 설계 결정이며 다음 계획이 검사로 받는다.

### 검사 #22

`enforce.spec.ts` 는 Files to Change 에 없고 검사 #22 의 허용 목록에도 없다. 편차 1 의 이유 (강제 ON 경로의 harness) 로 고쳤다. 게이트 사이드카와 허용 목록은 바꾸지 않았다. 리뷰가 허용 목록에 넣을지, harness 변경을 되돌릴 다른 설계를 택할지 정한다.

### 시험 · 검증 명령 (구현 뒤 추가 실행)

- `npx jest apps/api/src/modules/auth/access-control apps/api/src/modules/auth/admin apps/api/src/modules/auth/permission --testPathIgnorePatterns=itspec` → 411 건 녹색 (마지막 코드 변경 뒤).
- 격리 itspec (m6c 설정, IT_MYSQL_* 로 격리 컨테이너 `m9b-iso-db-…`): `judge-cancel`, `enforce-cross-tenant`, `evaluate-absent` 모두 녹색.
- `npx tsc --noEmit -p tsconfig.json` 오류 23 (기준 23).
- 서버 `prettier --check` 바뀐 19 파일 통과. 편차 4 참고.

### 격리 DB · probe

- 격리 MySQL: `m9b-iso-db-3142509-25297` (mysql:8.0.42, 합성 테넌트 · 스키마 · 권한 시드 적재). 이 노트를 쓴 뒤 `docker rm -f` 로 내린다 (m9b-iso- 접두 컨테이너만).
- probe: 착수 빌드 (`92b5aa07`) 와 워킹트리 빌드 모두 `rows=6336 writes=12`. 본문 cmp `yes`.
- probe 의 after_sha 는 `HEAD` (커밋하지 않았으므로 착수 sha 와 같다) 다. 행은 워킹트리 빌드다 (편차 9).

### 검사 #13 중 파일 위치

- 백로그 짝 행은 계획 커밋에서 append 한 여섯 개다 (검사 #14 · #16 의 PAIR_ROWS=6). 이번 구현은 백로그를 건드리지 않았다.

## Codex Implementation Review

- 호출: `codex-invoke.js adversarial-review` (fail-closed wrapper, 라운드 상한 3 봉인: `review-rounds seal` cap=3).
- R1: `verdict=needs-attention`, medium 1 — 강제 ON 에서 판정 대상이 아닌 익명 · 무효 쿠키 요청이 연결을 획득한다 (현행은 DB 접근 0회). 흡수: `enforce()` 가 판정 대상 (관리자 신원 · 열람 토큰 헤더) 이 아니면 풀을 쓰지 않고 넘긴다. 회귀 시험 「익명 요청은 스위치가 켜져도 풀을 쓰지 않는다」 추가.
- R2: `verdict=needs-attention`, medium 1 — 열람 토큰 헤더만 있는 익명 요청이 여전히 풀을 잡는다. 흡수: 열람 토큰 합성 판정을 풀 밖에서 만든다 (`viewToken` 분기, `judge` 의 합성 분기 제거).
- R3 (마지막 라운드): `verdict=approve`. 블로킹 지적 없음. 수렴.
- security-reviewer (Task 도구): 이 실행에는 Task 도구가 없어 호출하지 못했다. 인증 · 권한 경로는 Codex 세 라운드가 봤다. `security_skipped` 사유는 「이 실행에 Task 도구 없음」 이다. `/mccp:pr` 이 이 사유를 막는 항목으로 볼 수 있다 (리뷰 몫).
- impeccable: `silent_skip` (서버 전용 diff, 디자인 표면 없음). 설계 게이트는 해당 없음.

### receipt

- `.claude/receipts/mccp-implement-codex/admin-rbac-m9b.json`: `codex_verdict=converged`, `rounds=3`, `plan_hash` 는 계획서 현재 내용과 일치 (계획서는 고치지 않았다).
- `receipt validate`: exit 2 — `missing: mccp-plan-codex (no receipt written)`. skip-receipt 지시에 따라 plan 게이트 receipt 는 없다. `MCCP_SKIP_RECEIPT=1` 을 validate 에 붙여도 이 검사는 통과하지 않는다 (validate 는 그 env 를 읽지 않는다). 이 결과는 리뷰와 PR 단계가 처리한다.

## 커밋 상태

- 커밋하지 않았다 (부모 · 서브모듈 모두). 사용자 지시: 커밋 전 별도 세션에서 code-review.
- 부모: `.claude/notes/admin-rbac-m9b.md`, `.claude/prds/admin-rbac.prd.md` (결정 로그 절), `.claude/meta/2026-10-23-admin-rbac-m9b/` (probe · 행 파일 · 격리 스크립트), `.claude/PRPs/reports/admin-rbac-m9b-report.md`.
- 서버 서브모듈 (`insuniverse-server`, feat/admin-rbac): CI 목록 `.github/workflows/api.yml` 포함. 바뀐 파일 목록은 `git -C insuniverse-server status --short` 로 본다.
- 어드민 · 사이트: 변경 없음.

## 격리 실부팅 (코드 리뷰 R1)

앱 전체를 격리 MySQL (8.0.42) · 격리 Redis 위에 부팅해 probe 를 돌렸다 (`isolated-probe.sh`, `GK_BUILD=after`). 앱은 빌드 산출을 `env -i` 로 기동하고 DB 는 합성 시드다. 행 형식과 비교 방식은 검사 #7 과 같다.

- 스위치 OFF (게이트 #7 의 `before_eq_after`): 착수 행 파일과 본문 cmp 가 같아야 한다. 결과는 「## 코드 리뷰 R1 뒤 실행 결과」 에 적는다.
- 영역 1 임시 ON: `access-control.constants.ts` 의 `ENFORCE_AREA_ACCOUNT_TOOLS` 를 `true` 로 바꾼 사본으로 한 번 빌드·기동했다. probe 가 끝나면 원본을 `cmp` 로 되돌렸다 (`CONST_RESTORED_OK`, 상수 파일은 HEAD 와 같다).
- 결과 (ON 대 OFF, 행 6336 · 쓰기 12):
  - `admin-super` (최고관리자): 상태 변화 0. 200 응답 204건 불변, 쓰기 12건 불변. 대표 요청 200 이 유지된다.
  - `admin-general` (내부 관리자, 권한 행 있음 · 영역 1 코드 없음): 200 → 403 이 5행 (`GET /admin/admin`, `/admin/admin/1`, `/admin/authority`, `/admin/authority/1`, `/admin/authority/menus`). 400 → 403 이 6행 (검증 전에 판정이 닫는다). 403 → 403 본문만 바뀐 14행 (권한 거부 본문).
  - 열람 토큰 신원 (`view-token`): 401 → 403 이 25행. 영역 1 경로는 열람 토큰으로 열리지 않는다.
- 해석: 스위치 ON 에서 최고관리자는 200, 권한 없는 내부 관리자는 403 으로 닫힌다. 응답 본문은 해시만 기록하므로 E0607 코드 자체는 격리 itspec (`enforce-cross-tenant.itspec.ts`, `evaluate-absent.itspec.ts`) 이 본다.
- 이 실부팅은 영역 1 임시 ON 의 일회성 실측이다. 영역 상수는 커밋 상태에서 모두 OFF 다 (검사 #3). 복제 환경 1회 실행 (운영 쪽 완료 조건) 과 부하 실측은 사용자 몫 (미검증).
- 로그와 행 파일은 `.git/worktrees/admin-rbac/mccp-gatekit/m9b/` 아래에 있다 (커밋 산출 아님).

## 코드 리뷰

리뷰 입력: code-review (HIGH 4 · MEDIUM 2 · LOW 2), security (MEDIUM 1 · LOW 2), typescript (MEDIUM 2 · LOW 5). 같은 자리를 여러 렌즈가 본 것은 중복으로 묶고 「중복」 행으로 연결했다. 판정 기준은 CLAUDE.md §4 (흡수는 수정 전후 반사실로 확인) 와 PRD 행 4 (fail-closed).

| 라운드 | 출처 | 심각도 | 지적 | 판정 | 증거 | 층위 |
|---|---|---|---|---|---|---|
| R1 | code-review | HIGH | 1907 전역 ON 가드가 복원 null · error 를 판정 없이 통과시킨다 (fail-open) | 흡수 | `access-control.guard.ts` 의 `unrestoredError` (null → 401, 판정 불가 → 503, 그 밖의 오류 → 503). 판정: PRD 행 4 「DB 오류를 전권으로 대체하지 않는다」 에 따라 fail-closed, 뒤 가드에 기대지 않고 자기에서 닫는다. 시험 「복원 실패 … 503 으로 닫고 (fail-closed)」 · 「복원 기록 null … 401」 (검사 #30). 반사실: `return true` 로 되돌리면 2건 붉음 (2 failed), 복구 뒤 녹색. 백로그 짝 행 RESOLVED-IN (1907) | 2 |
| R1 | code-review | HIGH | 1906 KILL 응답 정체 경로가 시험되지 않았다 | 흡수 | `judge-cancel.spec.ts` 「KILL 응답이 멈추면 전용 연결을 파기하고 다음 취소는 새 연결로 나간다 (1906)」 (검사 #11). 반사실: `resetKillConnection` 호출을 빼면 이 시험과 두 연속 시험이 붉다 (3 failed), 복구 뒤 녹색. 백로그 짝 행 RESOLVED-IN (1906) | 2 |
| R1 | code-review · typescript | HIGH · MEDIUM | 검사 #29 「같은 값」 이 항진이다 (기대값이 가드 산출에서 나온다) | 흡수 | `consumers-same-value.spec.ts` 를 손으로 적은 기대값과 낡은 원 토큰 payload (role admin · corpScope ALL · agcId 1 · admId 99 · pv 4) 로 가드 경로에 다시 썼다. 반사실: `jwt.guard.ts` 가 payload 로 덮으면 2건 붉다 (2 failed). 제목 「같은 값」 은 유지 | 2 |
| R1 | code-review | HIGH | 칸 · 표 부재 「격리 실부팅」 주장이 가드 직접 호출로만 실측됐다 | 흡수 | 「## 격리 실부팅 (코드 리뷰 R1)」: 앱 전체 부팅, 영역 1 임시 ON, 원본 cmp 복구. 대표 요청 최고관리자 200 불변 · 내부 관리자 200 → 403. itspec 주석을 「앱 전체 부팅은 이 시험이 아니다」 로 정정 (편차 5). 검사 #10 제목은 그대로 | 2 |
| R1 | code-review | MEDIUM | 선택 가드: 확정 기록이 null · error 여도 열람 토큰 병합이 역할 · 관리자 번호를 채운다 | 흡수 | `admin-optional.guard.ts` 병합 조건을 `record === undefined` 로 바꿨다 (확정 기록이 있으면 병합하지 않는다). 시험 「확정 기록이 null 이면 열람 토큰도 신원을 되살리지 않는다」 (검사 #28). 반사실: 옛 조건으로 되돌리면 1건 붉다 (1 failed). 실경로 한계는 L12 행 | 1 |
| R1 | code-review · typescript | MEDIUM | `resetKillConnection` 이 실패한 연결이 아니라 맵의 현재 연결을 파기한다 (ABA) | 흡수 | `resetKillConnection(pool, failed)`: 맵 항목이 `failed` 와 같을 때만 지우고, 실패한 연결은 항상 파기한다. 시험 「늦은 KILL 실패는 뒤에 만든 새 연결을 지우지 않는다 (ABA)」. 반사실: 무조건 delete 로 되돌리면 붉다 (1 failed) | 1 |
| R1 | security | MEDIUM | KILL 정리 실패가 로그 없이 삼켜진다 (운영에서 취소가 안 먹는 이유를 알 수 없다) | 흡수 (로그 · 재시도) | `killQuery` 실패마다 `warn` (원인 code 만, 설정 · 비밀 미기록). 시한 초과는 재시도 없이 끝내고, 그 밖의 실패는 새 연결로 한 번 더 보낸다 (R1 typescript LOW 「끊긴 캐시 연결」 과 같은 수정). 부하 시험은 이번 라운드에 없다 (미검증, 잔여) | 1 |
| R1 | typescript | MEDIUM | (위 ABA 행과 같은 지적) | 중복 | 「resetKillConnection … (ABA)」 행과 같다 | 1 |
| R1 | typescript | MEDIUM | (위 「같은 값」 행과 같은 지적) | 중복 | 「검사 #29 「같은 값」 …」 행과 같다 | 2 |
| R1 | code-review | LOW | 편차 4 의 포맷 수치가 실측과 다르다 | 흡수 | 편차 4 를 실측으로 정정 (바뀐 서버 `.ts` 19 = 기존 10 + 새 9, HEAD 비-prettier 2 파일 · 포맷 차이 40 · 15 줄, 기존 파일 기준 563 · 76 줄). 커밋 단계에서 `style(...)` 분리 | 2 |
| R1 | code-review | LOW | 검사 #7 행의 줄 수 (6353 대 6336) 와 3.6 MB 원시 산출물 | 흡수 | 6353 = 행 파일 줄 전체: sha 1 + `row` 6336 + `write` 12 + `hv` 4. 6336 은 `row` 행 수다. 원시 산출물은 검사 #7 의 cmp 기준이라 유지한다 (`.claude/meta/` 는 #22 허용 범위). 크기와 용도는 여기 적는다 | 2 |
| R1 | code-review | LOW | PRD 행 4 문언과 다른 super-unknown 503 편차가 PRD 결정 로그에 없다 | 흡수 | PRD 「2026-10-23 · 관리자 RBAC 마일스톤 9b 보완」 절에 편차 bullet 추가 (절 수 1 유지, 검사 #20). 평가기 계약 주석 정정은 최종 리뷰 몫 | 2 |
| R1 | code-review | LOW | `admin-principal.spec.ts` 의 「덮어쓰지 않는다」 시험은 넣은 값을 되돌려 받는 항진이다 | 흡수 | 항진 시험을 지웠다. 남은 참조 동일성 시험의 제목에 「덮어쓰지」 를 넣었다 (검사 #5 토큰 유지). 실제 덮어쓰기 증명은 `admin-guard-confirmed.spec.ts` 와 `consumers-same-value.spec.ts` 가 가드 경로로 본다 | 2 |
| R1 | code-review | LOW | `enforce.spec.ts` 하네스 변경이 계획 Files to Change 밖이고 실경로 커버리지를 줄인다 | 흡수 (기록 · 게이트) | 검사 #22 허용 목록에 `enforce.spec` 을 넣었다 (검사 의미 유지, expect 줄은 손대지 않았다). 편차 1 에 커버리지 감소를 적었다. 계획 Files to Change 행은 계획 동결 (계획서 수정 금지) 으로 넣지 않았다 | 1 |
| R1 | code-review | LOW | NULL 신원 시험이 E0607 코드를 보지 않는다 | 흡수 | `enforce-cross-tenant.itspec.ts` 에 `status 403 · code E0607` 을 더했다. 격리 실행 녹색 | 2 |
| R1 | code-review | LOW | `judge-cancel` 의 release 분기가 사실상 닿지 않는다 | 기각 | 거절로 끝난 작업의 연결을 풀로 되돌리면 끊긴 연결이 풀에 남을 수 있다. 보수 쪽 (파기) 을 택했다. 주석을 「정상 종료」 로 정정했다. 계획 문구 「작업이 끝났고」 의 해석 차이는 최종 리뷰 몫 | 1 |
| R1 | code-review | LOW | 정리 프라미스에 catch 가 없어 unhandled rejection 이 날 수 있다 | 흡수 | `cleanupAfterDeadline(...).catch(() => conn.destroy())` | 4 |
| R1 | code-review · security | LOW · LOW | 선택 가드 분기가 실경로 라우트에서 닿지 않는다 (맵에 선택 가드 라우트 0건) | 부분 흡수 | 단위 시험 (M1 행) 과 병합 조건 수정으로 닫았다. 실경로는 미검증으로 남긴다 (선택 가드 라우트가 권한 맵에 들어올 때 다시 본다) | 6 |
| R1 | code-review · security · typescript | LOW ×3 | `evaluate-absent.itspec` 파괴적 DDL: 대상 DB 확인 없음 · afterAll 이 한 줄 실패에 나머지를 건너뛴다 | 흡수 | 합성 표지 행 (`adm_login_id = 'm9b-super'`) 이 없으면 DDL 을 돌리지 않는다. 적용된 이름 변경만 각각 되돌린다 (실패는 마지막에 던진다). 격리 실행 녹색 | 5 |
| R1 | typescript | LOW | `judge-cancel.spec.ts` 가 고정 650 ms 를 기다린다 (배포 게이트 CI 에서 흔들림) | 흡수 | 고정 대기를 폴링 (`waitFor`, 시한 2 s) 으로 바꿨다 | 밖 (시간 의존) |
| R1 | typescript | LOW | 죽은 캐시 KILL 연결을 감지하지 않는다 (끊긴 뒤 첫 취소가 실패한다) | 흡수 | `killQuery` 가 시한 아닌 실패에서 새 연결로 한 번 더 보낸다. 시험 「KILL 연결이 끊겨 있으면 새 연결로 한 번 더 보낸다」. 반사실: 재시도를 빼면 붉다 (1 failed) | 1 |
| R1 | typescript | LOW | 가드의 null 복원 · reissue 경로 시험이 없다 | 흡수 | 시험 「확정 기록이 null 이면 컨트롤러 가드는 401 …」 (기존) · 「확정 기록의 재발급 판단이 참이면 쿠키를 한 번 다시 쓰고 …」 (추가). 전역 가드의 null 은 L1 행의 시험 | 2 |
| R1 | typescript | LOW | 포맷 churn 이 보안 파일에 섞였다 | 기각 (커밋 단계로 이월) | 계획 Task 9 (b) 와 검사 #26 이 prettier 통과를 요구한다. 리뷰의 부분 되돌림 제안은 검사 #26 과 충돌한다. 분리 커밋 (`style(...)`) 은 커밋 단계 몫이다 (편차 4) | 밖 (정책 충돌) |
| R1 | typescript | LOW | `judge-cancel.itspec` 「풀 회복」 이 연결 수를 세지 않는다 | 기각 | 계획 Acceptance 는 「다음 판정이 정상」 이고 연결 수를 요구하지 않는다. 연결 수 확인은 부하 실측 (ON 체크리스트) 의 몫 | 6 |

| R2 | security · typescript | HIGH (중복) | KILL 전용 연결에 `error` 리스너가 없어 유휴 상태에서 서버가 끊으면 프로세스가 죽는다 (지적자 재현: exit 1) | 흡수 (하드닝 · 지적 재현 불일치) | 실 mysql2 3.11.0 · MySQL 8.0.42 에서 `KILL CONNECTION` 으로 유휴 KILL 연결을 끊은 실측: 리스너 없이도 프로세스는 살아 있었다 (`mccp-gatekit/m9b-r2/idle-kill-probe.js`). 생성 직후 core `error` 리스너는 1개이고 (연결 생성기의 `once` 리스너), 첫 error 를 삼키면서 0 이 된다 (`idle-kill-diag.js`). 따라서 지적의 「유휴 끊김 한 번이면 죽는다」 는 재현되지 않았다. 죽을 수 있는 경로는 첫 error 가 이미 소비된 뒤의 두 번째 error (비치명 emit 뒤의 끊김 등) 이며 미실측이다. 흡수 내용: `judge-cancel.ts` `killConnection` 에 상시 `error` 리스너를 달아 맵에서 빼고 파기하고 원인 코드만 로그에 싣는다 (암묵 의존 제거, 죽은 캐시 즉시 제거). 시험 「KILL 연결이 서버에서 끊겨 error 가 나도 프로세스를 죽이지 않고 …」 (stub 은 EventEmitter, judge-cancel.spec, 검사 #11). 반사실: 리스너 줄을 빼면 그 시험이 붉다 (1 failed). | 6 |
| R2 | code-review | MEDIUM | 시한 초과 뒤 판정 연결이 KILL 과 정리 (최대 약 1 s) 동안 풀 슬롯을 쥐고 있어, DB 지연 때 풀 대기가 번진다 | 흡수 | `judge-cancel.ts` 시한 catch 가 `conn.destroy()` 를 KILL 보다 먼저 한다 (슬롯 즉시 반환, KILL 은 기존 전용 연결로 뒤에 나간다). KILL 이 소켓 종료 뒤에도 먹는지는 격리 MySQL 로 확인 (`judge-cancel.itspec` 의 「시한 초과 쿼리 취소」 · 「포화에서도」 가 PROCESSLIST 로 취소를 본다). 시험 「시한을 넘으면 KILL 응답을 기다리지 않고 판정 연결을 즉시 파기해 풀 슬롯을 돌려준다 (슬롯)」 는 KILL 이 멈춰도 거절 시점에 파기가 끝나 있음을 본다. 반사실: 파기를 KILL 뒤로 되돌리면 그 시험이 붉다 (1 failed). 부하 상한의 실측은 여전히 미검증 (ON 전) | 1 |
| R2 | code-review | LOW | 강제 ON 의 복원 실패 원인이 로그에 없다 (PVU 외 오류는 503 만 남는다) | 흡수 | `access-control.guard.ts` `restoreRecorded` catch 가 `복원 실패: <원인 코드>` 를 warn 한다 (메시지 · 본문은 싣지 않는다). 시험 「복원 실패 원인: … 원인 코드를 로그에 남긴다」 (guard spec). 반사실: 로그 줄을 빼면 붉다 (1 failed) | 1 |
| R2 | code-review | LOW | 복원 실패 응답의 PVU 분기 (`PERMISSION_VERSION_UNAVAILABLE` 503) 와 선택 가드 error 분기에 시험이 없다 | 흡수 | guard spec 「판정 불가: 권한 버전을 못 읽으면 PERMISSION_VERSION_UNAVAILABLE 로 503 …」 (PVU 분기는 메시지 값을 본다). admin-guard-confirmed spec 「확정 기록이 오류면 선택 가드는 익명이고 원인을 로그에 남긴다」 (PVU 는 warn, 그 밖은 error, 신원 칸 없음). 반사실: PVU 분기를 HALT 로 바꾸면 붉다 (1 failed) · 선택 가드 error 분기에 신원을 넣으면 붉다 (1 failed) | 2 |
| R2 | code-review | LOW | 「풀 회복」 시험 제목이 실제 기전 (파기로 슬롯 반환) 과 다르다 | 흡수 | `judge-cancel.itspec.ts` 제목을 「풀 회복 — 시한 초과 뒤 판정 연결을 파기해 풀 슬롯을 돌려주고 다음 판정이 정상이다」 로 고치고, 검사 #12 토큰 「풀 회복」 은 유지했다. 주석도 기전에 맞췄다. 격리 실행 3건 녹색 | 2 |
| R2 | code-review | LOW | 교차 기업 시험이 tn_admin 에 합성 행을 넣고 지우지 않으며, 같은 번호의 실 행이 있어도 덮어쓴다 | 흡수 | `enforce-cross-tenant.itspec.ts`: beforeAll 이 adm_id 9 · 10 의 기존 행 수를 세고 0 이 아니면 넣지 않고 멈춘다. afterAll 이 번호와 표지 아이디 (`m9b-norole` · `m9b-nullath`) 가 맞는 행만 지운다. 격리 실행 5건 녹색, 실행 뒤 합성 행 0. 반사실 (1): 삭제를 뺀 사본은 실행 뒤 행 2 가 남는다. 반사실 (2): 번호 9 에 실 행을 넣으면 beforeAll 이 멈추고 실 행은 그대로 남는다 (확인 뒤 지웠다). DB 이름 자체는 검사하지 않는다 (env 는 게이트 몫, 잔여) | 1 |
| R2 | typescript | LOW | `work(db)` 가 동기로 던지면 풀 연결이 release · destroy 없이 샌다 | 흡수 | `judge-cancel.ts`: `const workP = new Promise<T>((resolve) => resolve(work(db)))` (프라미스 생성자 안에서 불러 동기 throw 가 거절이 된다). 시험 「작업이 동기로 던져도 판정 연결을 풀로 되돌리고 그 오류를 낸다」. 반사실: 원래 호출로 되돌리면 붉다 (1 failed, release 0) | 1 |
| R2 | typescript | LOW | `drizzle(conn) as unknown as JudgeDb` 이중 캐스트가 타입 점검을 버린다 (guard 의 `admId as number` 도 같은 종류) | 흡수 | `judge-cancel.ts` 캐스트 제거. 서버 tsc 오류 23 (기준 23, 새 오류 0). guard 의 `record.result.claims.admId` 는 `typeof … number` + `Number.isInteger` 검사로 바꿨다 (정수가 아니면 null → 복원 미확정 503). 시험 「확정 신원 번호가 정수가 아니면 판정하지 않고 503 으로 닫는다 (캐스트 없음)」. 반사실: 캐스트 버전으로 되돌리면 붉다 (1 failed). 캐스트 제거 자체는 타입 수준이라 런타임 반사실이 없다 (tsc 기준선으로 본다) | 1 |
| R2 | typescript | LOW | ABA 시험이 벽시계 여유 (80 · 100 ms) 에 기대 배포 전 CI 에서 흔들린다 | 흡수 | `judge-cancel.spec.ts` ABA 시험을 가짜 시계 (`jest.useFakeTimers` · `advanceTimersByTimeAsync`) 로 옮겼다. 시각 순서가 정해져 있어 실시계 여유가 없다. 반사실: ABA 보호 조건 (`killConns.get(pool) === failed`) 을 빼면 K3 가 생겨 붉다 (1 failed) | 밖 (시간 의존, 흡수) |
| R3 | code-review | HIGH | 사이드카 검사 #15 · #16 이 붉어 30검사 rc 0 을 못 채운다 (#15: 이월 표에 없는 HIGH 표지 1906 · 1907 · 1917, #16: 추가 줄 대 짝 행 수) | 미흡수 (메인 판정 대상) | 계획서가 동결돼 이월 표를 고칠 수 없다. 검사 정의를 바꾸면 결과를 맞추는 일이라 택하지 않았다. 백로그 HIGH 행 (1917) 과 짝 행 (1918) 으로 넘긴다. 게이트 결과는 「## 코드 리뷰 R3 뒤 실행 결과」. 1906 · 1907 은 짝 행이 RESOLVED-IN 이고 시험은 녹색이다 | 6 |
| R3 | code-review | MEDIUM | 소비처 검사 #4 정규식이 9줄을 놓친다 (`req?.admin` 2 · `(req as …).admin` 6 · `getRequest().admin` 1) | 흡수 | `CENSUS_RE` 를 보강했다 (`(req\|request)\??(…)` · `\(req as [^)]*\)\.admin` · `getRequest(<…>)?\(\)\.admin`). 기준 커밋 대조 줄 수 152 → 161 (추가된 9줄은 지적 목록과 같다). 검사 #4 의 자기 시험 「CENSUS_FORMS」 가 다섯 형태를 세고 `req.accessControl` 은 세지 않는다. 반사실: 옛 정규식은 같은 입력에서 2 를 내고 (붉음), 새 정규식은 5. 전체 게이트에서 #4 녹색 | 6 |
| R3 | code-review | MEDIUM | 포화 시험이 KILL 의 풀 대기 회피를 증명하지 않는다 (KILL 을 풀로 바꿔도 녹색) | 흡수 | 풀이 찬 뒤 대기 요청 C 를 세우고 판정 연결 B 가 파기되면 빈 슬롯은 C 가 받게 했다. C 는 연결을 쥔 채 확인한다. 반사실: KILL 을 `pool.promise().getConnection()` 경유로 바꾸면 「포화에서도 …」 시험이 60 s 시한으로 붉다 (`judge-cancel.itspec.ts`). 원 코드로 되돌린 뒤 세 시험 녹색 (격리) | 2 |
| R3 | code-review | MEDIUM | 칸 · 표 부재 「격리 실부팅」 라벨이 가드 수준 실측과 다르다 | 부분 흡수 (라벨 정정 · 계획 문구는 동결) | 검사 #10 주석 · `evaluate-absent.itspec.ts` 주석 · 편차 5 를 「가드 · 평가기 · 실제 DB」 로 정정했다. 앱 전체 부팅으로는 영역 1 ON 만 봤다 (「## 격리 실부팅 (코드 리뷰 R1)」). 영역 2 ON 과 칸 · 표 이름 변경 상태의 앱 부팅은 하지 않았다 (미실측). 계획 Acceptance · Files 표 · Task 4 의 「격리 실부팅」 은 계획 동결로 그대로 둔다 → 백로그 MEDIUM 행 (1919) | 2 |
| R3 | code-review | LOW | 교차 기업 음성은 가드 경로가 아니라 컨트롤러 범위 판정이고, 응답 코드가 기록되지 않았다 | 흡수 (응답 실측 추가) | 시험이 실제 응답을 적는다: 상태 403 · 본문 `{ code: 'E0607', reason: 'scope', message: '담당 범위 밖의 머니 내역은 조회할 수 없습니다' }`. 경로는 범위 상수 ON 일 때 `assertSingleOwnedAgc` (`money-admin.controller.ts:163`) 가 낸다. 이전 메시지 (「다른 기업의 지급 내역…」) 는 그 뒤 줄 (`:174`) 의 문구였다. 편차 6 을 정정했다. 측정만 더한 것이라 반사실은 없다 (`enforce-cross-tenant.itspec.ts`, 격리 녹색). 「강제 ON」 제목 범위는 편차 6 에 그대로 남긴다 | 2 |
| R3 | code-review | LOW | `ENFORCE_JUDGE_TIMEOUT_MS` 주석이 「서버 쿼리는 취소하지 않는다」 라고 쓴다 (취소 구현 뒤로는 틀린 말이다) | 미흡수 (범위 밖) | `access-control.constants.ts:29` 는 검사 #22 허용 목록 밖이다. 주석을 고치면 #22 가 붉는다. 허용 목록을 넓히는 것은 검사 의미를 바꾸는 일이라 하지 않았다. 다음 계획 Files 에 이 파일 (주석 한 줄) 을 넣어 정정한다. 주석은 지금 틀린 상태로 남는다 (잔여) | 1 |
| R3 | code-review | LOW | 작업 오류에서도 판정 연결을 풀로 되돌린다 (R1 기각 근거는 파기 쪽이라고 썼다, mysql2 처리는 미검증) | 기각 (근거 정정 · 실측) | mysql2 3.11.0 `lib/pool_connection.js:11-19` 는 치명 `error` · `end` 에서 연결을 풀에서 뺀다 (`_removeFromPool`). `lib/pool.js:84-98` 은 풀에서 뺀 연결을 빈 연결 목록에 넣지 않는다. 실측 (`mccp-gatekit/m9b-r3/release-after-kill.cjs`, 합성 DB): 비치명 오류 (`ER_NO_SUCH_TABLE`) 뒤 release 는 같은 연결을 재사용 (reused=true). 서버가 `KILL CONNECTION` 으로 끊은 연결은 release 뒤 다음 획득이 새 번호를 받는다 (reusedDead=false). 파기로 바꾸면 기존 시험 「작업이 동기로 던져도 …」 (release 1 · destroy 0) 이 깨진다. 판정 근거를 실측으로 고쳤다 (R1 기각 행의 「보수 쪽 (파기)」 는 틀린 설명이었다). `judge-cancel.ts` 에 근거 주석 한 줄 | 1 |
| R3 | code-review | LOW | 전역 가드의 복원 실패 닫기가 notes 편차 목록과 계획 Risks 10 에 없다 | 흡수 (편차 추가) | 편차 13 을 추가했다: 전역 가드는 복원 null 을 401, 판정 불가 (PVU 와 그 밖의 오류) 를 503 으로 자기에서 닫는다 (`access-control.guard.ts` `unrestoredError`). 계획 Risks 10 의 「뒤따르는 가드가 없는 라우트는 익명」 은 동결이라 그대로 두고 여기에 정정을 적는다 | 2 |
| R3 | security | LOW | KILL 이 「스레드가 이미 없음」 (1094) 을 실패로 다뤄 공유 KILL 연결을 파기하고 두 번 재연결하며 경고를 두 번 낸다 | 흡수 | `killQuery` 가 errno 1094 를 취소 성공으로 보고 연결을 그대로 둔다 (재시도 없음). 실측 (`mccp-gatekit/m9b-r3/kill-missing-thread.cjs`): 끝난 스레드에 `KILL QUERY` 는 `ER_NO_SUCH_THREAD` · errno 1094, 유휴 스레드 KILL 은 성공. 시험 「KILL 대상 스레드가 이미 끝났으면 (1094) …」 (`judge-cancel.spec.ts`). 반사실: 1094 분기가 없는 이전 코드에서 이 시험이 붉다 (createConnection 2회 · destroy 1회) | 2 |
| R3 | typescript | LOW | (위 1094 행과 같은 지적) | 중복 | 「KILL … 1094」 행과 같다 | 2 |
| R3 | typescript | LOW | 복원 실패 로그가 `PermissionVersionUnavailable` 의 사유 토큰을 버린다 (`enforce` 의 `e.reason` 과 다르다) | 흡수 | 가드의 `causeOf` 가 `reason` 토큰을 함께 낸다 (`PermissionVersionUnavailable:row-absent`). 사유는 고정 토큰 (`permission-version.ts:95-135`: `db-error` · `row-absent` · `not-integer` · `bump-not-applied`) 이라 로그에 싣는다 (메시지 · 본문은 싣지 않는다). 시험 「복원 실패 원인: 권한 버전 판정 불가는 사유 토큰도 …」. 반사실: 이전 코드에서 붉다 (1 failed) | 2 |
| R3 | typescript | LOW | `drizzle(conn, { schema })` 이 요청마다 관계 설정을 다시 만든다 (판정 작업은 select · execute 만 쓴다) | 흡수 | `judge-cancel.ts` 가 스키마를 넘기지 않는다 (`JudgeDb` 는 `PermissionReadDb`, `permission-version.ts:24-27`, select · execute 만). 스키마 import 를 지웠다. 측정 (`mccp-gatekit/m9b-r3/drizzle-ctor.cjs`): 가짜 165 표 형상에서 생성 0.036 ms 대 0.0005 ms. 지적자의 실 스키마 측정 (0.245 ms) 은 재측정하지 않았다. 서버 tsc 오류 23 (기준 23, 새 오류 0), 판정 시험 녹색 | 1 |

「중복」 행은 같은 지적을 다른 렌즈가 올린 것이다. 판정은 원 행을 따른다. 「## 코드 리뷰 R1 뒤 실행 결과」 는 게이트 재실행 결과다.

## 코드 리뷰 R1 뒤 실행 결과

게이트 사이드카 (`.claude/PRPs/plans/admin-rbac-m9b.gates.sh`, 코드 리뷰 R1 뒤 한 번 실행) 결과: **28 통과 · 2 실패** (검사 #15 · #16). 앞 「## 구현 뒤 실행 결과」 의 붉은 4건 가운데 #13 · #22 는 녹색이 됐고, #14 의 짝 인용은 실재한다. #15 · #16 은 구조적으로 남는다 (아래).

| 검사 | 결과 | 근거 |
|---|---|---|
| #1 | PASS | `ANCESTOR_OK=13` |
| #2 | PASS | 정책 7 · 계획 7 · PRD complete 3 |
| #3 | PASS | `AREA_OFF=3 ON_LITERAL=0 SCOPE_OFF=1 MENU_OFF=1` |
| #4 | PASS | `CENSUS_OUTSIDE_ALLOW=0` (기준 152 줄) |
| #5 | PASS | principal 시험 3건 녹색, 제목 토큰 「확정」 「복원」 「덮어쓰지」 |
| #6 | PASS | access-control 묶음 193건 녹색, `GUARD_SPEC_EXPECT_REMOVED=0` |
| #7 | PASS | OFF 스위치 probe `before_eq_after: yes` (행 6336 · 쓰기 12) |
| #8 | PASS | 격리 교차 기업 시험 5건 녹색 |
| #9 | PASS | evaluate 시험 23건 녹색 |
| #10 | PASS | 격리 칸 · 표 부재 시험 2건 녹색 (최고관리자 200 · 내부 관리자 403) |
| #11 | PASS | judge-cancel 단위 시험 9건 녹색 (1906 · 연속 취소 · ABA 시험 포함) |
| #12 | PASS | 격리 시한 취소 시험 3건 녹색 |
| #13 | PASS | `UNPAIRED_M9_HIGH=[]` (1906 · 1907 짝 행 RESOLVED-IN) |
| #14 | PASS | 짝 행 8 · 인용 누락 0 · 허상 0 |
| #15 | **FAIL** | `MISSING=[1906 1907]` (아래 1) |
| #16 | **FAIL** | `BACKLOG_ADD=17 PAIR_ROWS=8` (아래 2) |
| #17 | PASS | 회귀 묶음 642건 녹색 |
| #18 | PASS | tsc `TSC_BASE=23 TSC_NOW=23` |
| #19 | PASS | notes 필수 절 8개 전부 |
| #20 | PASS | PRD 결정 로그 절 1 |
| #21 | PASS | CI 네 줄 1 · 1 · 1 · 1 |
| #22 | PASS | `SCOPE_OUT_OF_SCOPE=0` (허용 목록에 `enforce.spec` 추가) |
| #23 | PASS | `HOST_PATHS=0 CONN_CONFIG_OUT=0` |
| #24 | PASS | 사용자 지시 원문 확인 exit 0 |
| #25 | PASS | plan-kit lint rc 0 |
| #26 | PASS | 바뀐 서버 `.ts` 19개 prettier 통과 |
| #27 | PASS | 산타 원장 9b 구획 머리 1 |
| #28 | PASS | 확정 가드 시험 9건 녹색 (재발급 · null 열람 토큰 포함) |
| #29 | PASS | 같은 값 시험 2건 녹색 (손으로 적은 기대값 · 낡은 payload) |
| #30 | PASS | 가드 ON 경로 시험 18건 녹색 (「복원 기록 null」 포함) |

1) **#15 는 계획서가 동결돼 있어 붉다.** 검사 #15 는 계획서 「## 이월 지적 반영」 표가 HIGH 표지 줄 전부를 덮는다고 본다. 1906 · 1907 은 계획 게이트 R3 뒤에 백로그에 올라와 표에 없다. 표를 고치려면 계획서를 고쳐야 하는데 이 리뷰는 계획서를 고치지 않는다. 다음 조치는 메인 세션이 정한다 (계획 재작성 · 검사 정의 변경 중 택일, 어느 쪽이든 판정 기록을 남긴다).
2) **#16 은 백로그 추가 줄 수가 짝 행 수와 다르다 (17 대 8).** 차이 9는 계획 게이트 R3 가 추가한 미해소 행 (1906 · 1907 HIGH 표지, 1908 · 1909 · 1910 · 1911 · 1912 · 1913 · 1914) 이다. 검사는 추가 줄 = 짝 행을 요구하므로 짝 행을 늘려도 맞지 않는다. 기존 줄의 삭제 · 수정은 0 이다 (`BACKLOG_DEL=0`). 행을 지우면 리뷰 기록이 사라지므로 하지 않았다. 검사 정의 (추가 줄을 짝 행과 미해소 행으로 나눠 센다) 를 바꿀지는 메인 세션이 정한다.
3) 이번 코드 리뷰 라운드의 HIGH · MEDIUM 미해소는 0 이다. 그래서 이 라운드가 백로그에 새로 쓴 미해소 행은 없다 (짝 행 2건만 썼다). 백로그의 1908 · 1909 MEDIUM 은 계획 게이트 R3 의 기존 행이다. 흡수하지 못한 항목은 「## 코드 리뷰」 표의 기각 3건이다.

## 코드 리뷰 R2 뒤 실행 결과

게이트 사이드카 (`.claude/PRPs/plans/admin-rbac-m9b.gates.sh`, R2 흡수 뒤 한 번 실행, 로그 `mccp-gatekit/m9b-r2/gates-r2-run1.out`) 결과: **28 통과 · 2 실패**. 실패는 #15 · #16 두 건이고 R1 과 원인이 같다 (아래 1 · 2). 실행은 KILL 연결 주석 두 곳을 고치기 전에 시작했다. 그 뒤 주석만 고쳤고 (동작 불변), judge-cancel 단위 12건 · 격리 3건을 다시 돌려 녹색을 확인했다.

| 검사 | 결과 | 근거 |
|---|---|---|
| #7 | PASS | 스위치 OFF 응답 본문 cmp (`CHECK_07=PASS`) |
| #8 | PASS | 격리 교차 기업 · 역할 없는 신원 시험 5건 녹색 |
| #10 | PASS | 격리 칸 · 표 부재 시험 2건 녹색 |
| #11 | PASS | judge-cancel 단위 시험 12건 녹색 (R2 추가 4건 포함) |
| #12 | PASS | 격리 시한 취소 시험 3건 녹색 (PROCESSLIST 로 취소 확인) |
| #15 | **FAIL** | `MISSING=[1906 1907]` (아래 1) |
| #16 | **FAIL** | `BACKLOG_ADD=17 PAIR_ROWS=8`, `BACKLOG_DEL=0` (아래 2) |
| #17 | PASS | 회귀 묶음 649건 녹색 (access-control · admin · permission · menu, itspec 제외) |
| #18 | PASS | 서버 tsc 오류 23 (기준 23, 새 오류 0) |
| #22 | PASS | `SCOPE_OUT_OF_SCOPE=0` (`enforce.spec` 은 R1 부터 허용 목록에 있다) |
| #26 | PASS | 바뀐 서버 `.ts` 전부 prettier 통과 (R2 에서 4 파일 `prettier --write`) |
| #28 · #29 · #30 | PASS | 확정 가드 · 같은 값 · 가드 ON 경로 시험 녹색 (#30 은 21건) |

1) **#15 는 계획서 동결 때문에 붉다.** 「이월 지적 반영」 표에 1906 · 1907 이 없다. 두 행은 계획 게이트 R3 뒤에 백로그에 올라왔다. 표를 고치려면 계획서를 고쳐야 하는데 이 라운드는 계획서를 고치지 않는다.
2) **#16 은 백로그 추가 줄이 짝 행보다 많다 (17 대 8).** 차이 9줄은 계획 게이트 R3 가 올린 미해소 행 (1906 · 1907 HIGH 표지, 1908 ~ 1914) 이다. 기존 줄 삭제는 0이다. 행을 지우면 리뷰 기록이 사라지므로 지우지 않았다. R2 는 미흡수 HIGH · MEDIUM 이 없어서 백로그에 새 행을 쓰지 않았다.
3) **#15 · #16 을 초록으로 만드는 길은 둘뿐이다.** 계획서를 다시 쓰거나 검사 정의를 바꾼다. 둘 다 이 라운드의 범위 밖이고, 검사 정의를 맞추는 쪽은 결과를 맞춘 것이므로 택하지 않았다. 메인 세션이 정한다.

### 이번 라운드의 반사실 (수정 전 붉음 · 수정 뒤 녹색)

- 유휴 KILL 연결의 `error` 리스너를 뺌 → 「유휴 끊김」 시험 1건 붉음.
- 시한 초과 파기를 KILL 뒤로 되돌림 → 「슬롯」 시험 1건 붉음.
- ABA 보호 조건 (`killConns.get(pool) === failed`) 을 뺌 → 「ABA」 시험 1건 붉음 (K3 생성).
- 작업 동기 throw 를 원래 호출로 되돌림 → 「동기로 던져도」 시험 1건 붉음 (release 0).
- 복원 실패 원인 로그 줄을 뺌 → 「복원 실패 원인」 시험 1건 붉음.
- PVU 분기를 HALT 로 바꿈 → 「판정 불가」 시험 1건 붉음.
- 선택 가드 error 분기에 신원을 줌 → 「확정 기록이 오류면」 시험 1건 붉음.
- `admId as number` 캐스트 버전으로 되돌림 → 「캐스트 없음」 시험 1건 붉음.
- 합성 행 정리 (afterAll delete) 를 뺌 → 시험은 녹색이고 실행 뒤 `tn_admin` 에 행 2건이 남는다 (상태로 판정).
- 번호 9 에 실 행이 있으면 beforeAll 이 멈추고 실 행은 그대로 남는다 (확인 뒤 제거).

### 실 mysql2 유휴 끊김 측정 (HIGH 지적의 재현 여부)

- 실 mysql2 3.11.0 과 격리 MySQL 8.0.42 에서 유휴 KILL 연결을 `KILL CONNECTION` 으로 끊었다 (`mccp-gatekit/m9b-r2/idle-kill-probe.js`).
- 리스너 없이도 프로세스는 살아 있었다. 연결 생성기 (`createConnection`) 가 등록한 일회성 `error` 리스너가 첫 error 를 삼키고, 소비 뒤 리스너 수가 0 이 된다 (`idle-kill-diag.js`: 생성 직후 1, 끊김 뒤 0).
- 따라서 지적의 「유휴 끊김 한 번이면 프로세스가 죽는다」 는 이 경로에서 재현되지 않았다. 죽을 수 있는 경로는 첫 error 가 이미 소비된 뒤의 두 번째 error (예: 비치명 emit 뒤의 끊김) 이며 미실측이다.
- 흡수 내용은 방어 강화다. 상시 `error` 리스너로 죽은 캐시 연결을 즉시 맵에서 빼고 파기하며, 일회성 리스너라는 암묵 의존을 없앤다. 판정 등급은 HIGH 로 받았지만 재현은 불일치로 적는다.

### 앱 전체 ON 실부팅 재실행 (영역 1 임시 ON, 원본 cmp 복구)

- R1 과 같은 절차 (`mccp-gatekit/m9b/on-area1-probe.sh`). 상수 복구 `CONST_RESTORED_OK`, 상수 파일은 실행 전과 같다.
- 행 6336 · 쓰기 12 (OFF 기준과 같다). 상태 전이는 R1 과 같다: `admin-general` 200 → 403 이 5행, 400 → 403 이 6행, `view-token` 401 → 403 이 25행. `admin-super` 는 변화 0 (대표 요청 200 유지).
- OFF 응답 불변은 검사 #7 이 본다 (PASS). 로그는 `mccp-gatekit/m9b-r2/on-area1-r2.out`, 결과 행은 `mccp-gatekit/m9b/probe-on-area1/`.

### 메인 지시 항목 대조

- 1) 1906 · 2) 1907: R1 에서 흡수했고 짝 행 RESOLVED-IN 은 그대로 둔다 (검사 #13 · #14 PASS). R2 는 두 행의 구현 증거 시험을 바꾸지 않았다.
- 3) 검사 #22 의 `enforce.spec`: R1 에서 허용 목록에 넣었다 (검사 의미 불변).
- 4) 격리 앱 부팅: R1 에서 실측했고, R2 에서 ON 경로를 다시 재서 같은 결과를 얻었다.

### 미해결 · 잔여

- 검사 #15 · #16 (위 1 · 2). 메인 세션 판정 대상.
- 실 mysql2 에서 두 번째 error 가 나는 경로는 미실측 (위).
- DB 이름을 시험이 검사하지 않는다 (`IT_MYSQL_DATABASE` 는 게이트 env 몫, 잔여).
- 복제 환경 1회 실행과 부하 실측은 사용자 몫 (미검증, 변화 없음).
- 커밋하지 않았다. 판정 근거의 정본은 이 절과 「## 코드 리뷰」 표다.

## 코드 리뷰 R3 뒤 실행 결과

게이트 사이드카 (`.claude/PRPs/plans/admin-rbac-m9b.gates.sh`, R3 흡수 뒤 최종 실행, 로그 `mccp-gatekit/m9b/gates-r3-final2.out`): **28 통과 · 2 실패**. 실패는 #15 · #16 두 건이고 R1 · R2 와 원인이 같다 (아래 1 · 2). 30검사 rc 0 은 이 라운드에서도 달성하지 못했다. 검사 정의나 계획서를 바꾸면 결과를 맞추는 일이므로 하지 않았다 (메인 판정).

| 검사 | 결과 | 근거 |
|---|---|---|
| #1 · #2 · #3 | PASS | 조상 13 · 정책 7 · 계획 7 · PRD complete 3 · 영역 상수 OFF 3 · 범위 OFF 1 · 메뉴 OFF 1 · ON 리터럴 0 |
| #4 | PASS | `CENSUS_FORMS=5` (자기 시험) · `CENSUS_BASE_LINES=161` · `CENSUS_OUTSIDE_ALLOW=0` |
| #5 · #6 | PASS | principal 시험 녹색 · access-control 묶음 녹색 · `GUARD_SPEC_EXPECT_REMOVED=0` |
| #7 | PASS | 스위치 OFF 응답 본문 cmp (`before_eq_after: yes`, 행 6336 · 쓰기 12) |
| #8 · #10 · #12 | PASS | 격리 교차 기업 · 칸 · 표 부재 · 시한 취소 시험 녹색 (격리 MySQL `m9b-iso-db-248408-2565`) |
| #9 · #11 · #17 · #28 · #29 · #30 | PASS | 평가기 · 판정 시한 단위 · 회귀 묶음 · 확정 가드 · 같은 값 · 가드 ON 경로 녹색 |
| #13 · #14 | PASS | 붉은 HIGH 짝 없음 0 · 짝 행 9 · 인용 누락 0 · 허상 0 (새 HIGH 행 1917 의 짝 1918 포함) |
| #15 | **FAIL** | `MISSING=[1906 1907 1917]` (아래 1) |
| #16 | **FAIL** | `BACKLOG_DEL=0 BACKLOG_ADD=20 PAIR_ROWS=9` (아래 2) |
| #18 | PASS | 서버 tsc 오류 23 (기준 23, 새 오류 0) |
| #19 · #20 · #21 · #23 · #24 · #25 | PASS | notes 절 · PRD 절 1 · CI 네 줄 · 호스트 경로 0 · 지시 원문 exit 0 · plan-kit lint rc 0 |
| #22 | PASS | `SCOPE_OUT_OF_SCOPE=0` (`enforce.spec` 은 R1 부터 허용 목록에 있다. R3 에서 허용 목록은 바꾸지 않았다) |
| #26 | PASS | 바뀐 서버 `.ts` 19개 prettier 통과 |
| #27 | PASS | 산타 원장 9b 구획 머리 1 |

1) **#15 는 계획서 동결 때문에 붉다.** 이월 표에 1906 · 1907 (R3 게이트 HIGH 표지, 이미 RESOLVED-IN) 과 이번 라운드가 올린 1917 (HIGH) 이 없다. 표를 고치려면 계획서를 고쳐야 한다. 백로그 HIGH 행 1917 로 넘겼다 (메인 판정 · 다음 계획 반영 대상).
2) **#16 은 백로그 추가 줄이 짝 행보다 많다 (20 대 9).** 짝 없는 추가 줄 11개는 1906 · 1907 (HIGH 표지, 짝 행은 1915 · 1916), 1908 ~ 1914 (R3 게이트가 올린 미해소 행 7줄), 1917 · 1919 (이번 라운드가 올린 HIGH · MEDIUM 행) 이다. 이번 라운드가 더한 짝 행은 1918 (PARTIAL) 하나다. 기존 줄 삭제는 0이다. 검사 정의 (추가 줄을 짝 행과 미해소 행으로 나눠 센다) 는 메인 판정 대상이다.

### 메인 지시 항목 확인 (R3)

- **1) 1906 (KILL 정체):** 시험 「KILL 응답이 멈추면 전용 연결을 파기하고 다음 취소는 새 연결로 나간다 (1906)」 는 R1 에 흡수돼 있었다. R3 에서 반사실을 다시 돌렸다: `killQuery` 의 `resetKillConnection` 을 빼면 `judge-cancel.spec.ts` 가 3건 붉다 (1906 · 연속 취소 · ABA). 원 코드로 되돌린 뒤 `cmp` 로 확인했다 (`JC_RESTORED_OK`).
- **2) 1907 (전역 ON 가드 fail-open):** 흡수 방법은 「ON 영역에서 닫기」 (`unrestoredError`: null → 401, 판정 불가 → 503). 매핑 라우트별 뒤 가드 대조 검사는 넣지 않았다. 반사실: `unrestoredError` 를 `return true` 로 바꾸면 `access-control.guard.spec.ts` 가 6건 붉다. 원 코드로 되돌린 뒤 `cmp` 확인 (`GUARD_RESTORED_OK`).
- **3) 검사 #22 허용 목록의 `enforce.spec`:** R1 에서 넣었고 R3 에서 그대로다. R3 에서는 허용 목록에 파일을 더하지 않았다 (아래 「미해결 · 잔여」 의 `access-control.constants.ts` 항목 참고).
- **4) 격리 앱 실부팅:** R3 는 ON 경로의 판정 연결 (`judge-cancel.ts` 의 스키마 인자 · 1094 분기) 과 복원 실패 로그 (`causeOf`) 를 바꿨으므로 probe 를 다시 돌렸다 (`mccp-gatekit/m9b-r3/on-area1-r3.out`). 영역 1 임시 ON (원본 상수 `cmp` 복구 `CONST_RESTORED_OK`, 상수 파일은 HEAD 와 같다). 행 6336 · 쓰기 12. 전이는 R2 와 같다: `admin-general` 200 → 403 5행 · 400 → 403 6행, `view-token` 401 → 403 25행, `admin-super` 변화 0 (대표 요청 200 유지). 영역 2 ON 의 앱 실부팅은 하지 않았다 (백로그 1919).

### R3 반사실 · 실측 기록

- 1094 시험 (`judge-cancel.spec.ts`): 1094 분기가 없는 이전 코드에서 붉다 (createConnection 2회 · destroy 1회). 분기를 넣은 뒤 녹색.
- 포화 시험 (`judge-cancel.itspec.ts`): KILL 을 `pool.promise().getConnection()` 경유로 바꾼 사본에서 「포화에서도 …」 가 60 s 시한으로 붉다 (「풀 회복」 도 붉다, 누수 때문). 원 코드로 되돌린 뒤 세 시험 녹색.
- 가드 사유 로그 (`access-control.guard.spec.ts`): 이전 코드에서 「PermissionVersionUnavailable:row-absent」 시험이 붉다. 분기를 넣은 뒤 녹색.
- 소비처 정규식 (검사 #4 의 자기 시험): 옛 정규식은 다섯 형태 입력에서 2 를 내고, 보강한 정규식은 5 를 낸다. `req.accessControl` 은 두 정규식 모두 세지 않는다.
- 교차 기업 시험: 응답 실측을 적었다 (403 · `{ code: 'E0607', reason: 'scope', message }`). 측정만 더한 것이라 반사실은 없다.
- 실측 (`mccp-gatekit/m9b-r3/`, 합성 DB 전용):
  - `release-after-kill.cjs`: 비치명 오류 뒤 release → 같은 연결 재사용 (`reused=true`). 서버가 `KILL CONNECTION` 으로 끊은 연결은 release 뒤 다음 획득이 새 번호 (`reusedDead=false`). mysql2 3.11.0 의 `pool_connection.js:11-19`, `pool.js:84-98` 과 맞는다.
  - `kill-missing-thread.cjs`: 끝난 스레드에 `KILL QUERY` → `ER_NO_SUCH_THREAD` · errno 1094. 유휴 스레드 KILL 은 성공.
  - `drizzle-ctor.cjs`: 가짜 165 표 형상에서 핸들 생성 0.036 ms (스키마 인자 있음) 대 0.0005 ms (없음). 지적자의 실 스키마 측정 0.245 ms 는 재측정하지 않았다.

### 미해결 · 잔여

- 검사 #15 · #16 (위 1 · 2). 메인 판정 대상. 백로그 HIGH 행 1917 · 짝 행 1918.
- 칸 · 표 부재를 영역 2 ON 과 이름 변경 상태에서 앱 전체로 부팅해 본 실측은 없다 (MEDIUM, 백로그 1919). 계획 Acceptance · Files 표 · Task 4 · 게이트 #10 의 「격리 실부팅」 은 동결된 계획이라 그대로 두었다.
- `access-control.constants.ts:29` 의 `ENFORCE_JUDGE_TIMEOUT_MS` 주석은 「서버 쿼리는 취소하지 않는다」 로 남았다. 이 파일은 검사 #22 허용 목록 밖이다. 주석만 고치려고 허용 목록을 넓히지 않았다. 다음 계획 Files 에 넣는다 (LOW, 틀린 주석이 남는다).
- 교차 기업 시험의 제목 범위 (「강제 ON」) 는 편차 6 에 적힌 대로 넓다. 범위 상수가 거부를 정하는 경로만 본다.
- 복제 환경 1회 실행과 부하 실측은 사용자 몫 (미검증, 변화 없음).
- 커밋하지 않았다. 판정 근거의 정본은 이 절과 「## 코드 리뷰」 표다.

## 메인 판정 (2026-10-23) — 검사 #15 · #16 정의 수정

- 코드 리뷰 R3 가 남긴 HIGH 1917(#15 · #16 붉음)은 검사 정의 결함으로 판정했다. 두 검사가 계획 동결 뒤 생긴 표지 줄(계획 게이트 R3 의 1906 · 1907, 리뷰의 1917)과 리뷰 지적 줄까지 계획 시점 기준으로 셌다.
- #15 는 기준 커밋 d2cf29b 백로그의 표지 줄만 이월 표와 대조한다(뒤 줄은 #13 짝 검사 몫). #16 은 삭제 0 · 짝 행 1 이상만 본다.
- 실행: #13 · #14 · #15 · #16 PASS(TABLE_NUMBERS=108 · HIGH_MARKERS=106 · BACKLOG_ADD=21 · PAIR_ROWS=10). 반사실: 표에서 1894 행을 뺀 계획 사본에서 #15 MISSING=[1894] 로 붉다. 짝 행은 백로그 마지막 줄 `[원 1917]`.
- 남은 MEDIUM 1919(칸 · 표 부재 실측은 가드 수준, 계획의 「격리 실부팅」 문구)는 그대로 둔다.

## 최종 리뷰

리뷰 입력: fable 4건 중 3건 (LOW), codex 1건 (MEDIUM). 라운드 1/3. HIGH · CRITICAL 0. 판정 기준은 CLAUDE.md §4 (흡수는 수정 전후 반사실로 확인, 흡수 주장은 다음 라운드의 지적이 된다).

| 라운드 | 리뷰어 | 심각도 | 지적 | 판정 | 증거 | 층위 |
|---|---|---|---|---|---|---|
| R1 | codex | MEDIUM | 타입 검사 실행 실패를 PASS 로 처리한다. 검사 #18 이 `\|\| true` 로 종료 상태를 버리고 `error TS` 개수만 비교해, 컴파일러가 검사 전에 죽으면 0건으로 통과한다 | 흡수 | `gates.sh` 의 `c18`: 종료 상태를 잡고, 종료 0 이면 통과, 종료 1 · 2 (진단 출력) 는 진단 줄이 1건 이상일 때만 개수 비교, 그 밖의 종료 (메모리 부족 · 신호 · npx 실패) 는 실패. 반사실: 기준 커밋 사본의 `c18` 에 `exit 137` 을 내는 가짜 `npx` → `TSC_RC=0` 통과 (결함 재현), 고친 `c18` 은 같은 입력에서 `rc=1` 실패. `npm ERR` 종료 1 · 진단 0 건도 실패. 실제 tsc (종료 1 · 진단 23건) 는 두 버전 모두 통과 (`TSC_BASE=23 TSC_NOW=23`). 실제 tsc 의 `--noEmit` 종료는 1 이다 (2 가 아니다) | 4 |
| R1 | fable | LOW | 검사 사이드카 `gates.sh` 가 계획 봉인 (`plan_hash`, `.plan.md` 만) 밖이라, 구현 커밋 `e812d36` 에서 검사 #4 · #15 · #16 · #22 의 정의가 바뀌었는데 이를 잡는 게이트가 없다 | 흡수 (부분) | 봉인 값을 이 절에 적는다 (아래). 계획서 해시에는 넣을 수 없다 (계획 동결, `plan_hash` 는 `.plan.md` 만 덮는다). 정의 변경은 git 이력과 이 표에 남는다. 자동 경보 검사는 넣지 않았다: 검사 수가 30 에서 31 로 늘면 계획 본문의 「검사 30개」 와 어긋나고 계획은 고치지 않는다. 잔여: 봉인 값 갱신 확인은 사람이 한다 | 7 |
| R1 | fable | LOW | evaluate 변경이 감시 인터셉터의 판정 분류를 바꾼다. 표 · 칸 부재 창에서 최고관리자 `unknown\|table-absent` → `allow\|super`, 내부 관리자의 super-only 코드 `unknown` → `deny\|super-only`, 그 밖의 내부 관리자는 `unknown\|table-absent` 를 유지하되 질의가 하나 늘어 시한 초과로 접힐 수 있다. 계획 Task 4 가 「현행 경로는 바꾸지 않는다」 고 적은 것은 응답에만 참이다 | 흡수 (기록 · 판단 표 주의, 코드 변경 기각) | 근거: `git show 92b5aa07:…/permission/evaluate.ts` 의 (3)·(5) 는 부재 오류를 즉시 `unknown` 으로 돌려 (6) 을 건너뛴다. 현행은 `pending` 을 든 채 (6) 으로 내려간다 (`evaluate.ts:113-155`). 감시 키는 `decision\|reason` 이라 super 행과 super-only 행이 바뀐다. 응답 본문은 그대로다 (검사 #7). 기각한 대안: 감시 분류를 옛 값으로 되돌리는 감시 전용 분기. 비정상 상태 (표 · 칸 부재) 에서만 열리는 창을 위해 평가기에 감시용 분기를 더하는 것은 과하다. 정책 쪽 근거: 계획 Task 4 와 PRD 행 4 문언 (최고관리자 통과). 미측정: 시한 초과가 늘어나는 정도. 「영역별 would-deny 판단 표」 절에 주의 줄을 더했다 | 2 |
| R1 | fable | LOW | 전역 가드의 fail-closed 가 선택 가드 라우트가 맵에 들면 무효화한다. 「선택 가드 라우트는 맵에 없다」 는 전제를 지키는 검사가 없다 | 기각 | 현재 무영향 실측: `permission/route-permission-map.ts` 에 선택 가드 라우트 키가 없다. `GET /admin/push/:pshId` 는 맵에 없다 (맵의 `GET /admin/push/:pshId/send-history` 는 다른 라우트다). 선택 가드 사용처 예: `push.controller.ts:149`, `medication-standard-public.controller.ts:23`. fail-closed 는 편차 13 과 PRD 행 4 가 정한 결정이고, 맵 편입은 계획 개정 몫이다. 미래 편입 조건은 아래 「미해결 · 잔여」 에 적는다 | 2 |

### 사이드카 봉인 값 (R1 · 검사 정의 공시)

- `sha256sum .claude/PRPs/plans/admin-rbac-m9b.gates.sh` = `121b2697b78754cd375307c700ac66a783538d32cec8fc06e61f0e0256681e94` (이 커밋 시점, 검사 30개, 검사 #18 은 R1 에서 고쳤다).
- 검사 정의를 고치면 이 값을 같은 커밋에서 갱신하고, 바뀐 검사와 이유를 이 절에 한 줄 더한다. 갱신이 없으면 정의 변경이 공시 없이 들어간다.

### 반사실 · 실측 (R1)

- `c18` 기준 사본 (`git show HEAD:…gates.sh`) + 가짜 `npx` (종료 137): `TSC_BASE=23 TSC_NOW=0 TSC_RC=137`, 블록 종료 0 (통과). 고친 `c18` 같은 입력: 종료 1 (실패). 가짜 `npx` (`npm ERR`, 종료 1, 진단 0): 고친 `c18` 종료 1. 실제 `npx tsc --noEmit -p tsconfig.json` (서버): `TSC_RC=1`, `TSC_NOW=23`, 고친 `c18` 종료 0.
- 감시 분류 변화는 코드 읽기 (base 와 현행 `evaluate.ts` 의 (3)·(5)·(6) 비교) 로 확인했다. 시험 실행은 하지 않았다. 판단 표가 이 창의 행을 받을 때 이 차이를 전제로 한다.
- 검사 실행 (커밋 전, 로그 `mccp-gatekit/m9b/gates-r5-final.out`): **30 통과 · rc 0**. 첫 실행 (`gates-r1-final.out`) 은 격리 환경 변수가 없어 #7 · #8 · #10 · #12 가 `PROBE_ENV_UNSET` · `IT_ENV_UNSET` 으로 붉었다. 격리 MySQL (`m9b-iso-db-728434-24611`, `iso-db-up.sh` 로 새로 기동) 과 `M9B_ISO_ENV` · `M9B_SCHEMA_DIR` 를 넣어 다시 돌렸다. 코드 결함이 아니라 환경이 내려가 있던 것이다. 실행 뒤 그 컨테이너만 내렸다. probe 요약 `before_eq_after: yes`, 시험 제목 토큰 모두 있음.

### 미해결 · 잔여

- 선택 가드 라우트를 맵에 올리면 (예: `GET /admin/push/:pshId` 를 `push.read` 로) 영역 ON 뒤 만료 · 회수된 `_aaT` 쿠키 요청이 익명 200 이 아니라 401 을 받는다. 맵 편입 전에 계획 개정이 필요하다. 지금은 어느 선택 가드 라우트도 맵에 없다 (LOW).
- 감시 분류 변화의 시한 초과 빈도는 측정하지 않았다 (미검증, LOW). 표 · 칸 부재는 정상 운영 상태가 아니므로 평시 분류는 바뀌지 않는다.
- 검사 사이드카 자동 봉인은 없다 (위 봉인 값은 사람이 대조한다, LOW).
- `access-control.constants.ts:29` 의 `ENFORCE_JUDGE_TIMEOUT_MS` 주석 (「서버 쿼리는 취소하지 않는다」) 은 그대로다 (허용 목록 밖, LOW). 「## 메인 판정」 과 같은 이월이다.
- 이번 라운드 뒤 HIGH 흡수 0, 흡수하지 못한 HIGH · MEDIUM 0 이다. 백로그 추가 줄은 없다.
