# task-3000 — InsuRo 푸시 RPC 초크포인트 — 적용 후 검증 스위트 + 변이 봉인 하네스 (Morrigan / dev3 QA)

- 담당: 모리건 (개발3팀 테스트/QA)
- worktree: `/home/jay/projects/InsuRo/.worktrees/task-3000-dev3` (base=96ad2b2, 이후 루의 커밋 `4f68178` 이 마이그레이션+백업 추가)
- 확장한 파일: `/home/jay/projects/InsuRo/.worktrees/task-3000-dev3/tests/task-3000-push-endpoint-probe.py` (기존 6케이스 → 35케이스 + 변이 4종 하네스로 확장)
- DB: aws-1-ap-northeast-2.pooler.supabase.com:5432 (session mode, sslmode=require)
- 모든 쓰기·함수 교체는 `BEGIN...ROLLBACK` (변이는 그 안에서 다시 `SAVEPOINT...ROLLBACK TO SAVEPOINT`) 안에서 수행. **프로덕션 COMMIT 없음** (아래 "안전장치" 절 참조). ROLLBACK 이후 `push_subscriptions` 행 수·함수 sha256 모두 실측으로 원복 확인.
- 대상 마이그레이션: `supabase/migrations/20260822T020001_task3000_push_endpoint_chokepoint.sql` (루 작성, 실제로 존재함을 확인 후 리허설 진행 — 지시된 "팀장이 적용했다고 알려줄 때까지 대기" 조건 충족: 파일이 이미 존재했음)

## 실행한 것 / 실행 안 한 것 (인지 규율)

- 실행함: `--mode before` (라이브 미패치 상태, 회귀 없음 확인), `--mode after --apply-migration <파일>` (트랜잭션 내 리허설), `--mode mutation --apply-migration <파일>` (변이 4종 전부)
- 실행 안 함: 실제 프로덕션 COMMIT. `20260822T020000_*_ROLLBACK.sql` 실행 안 함(대상 자체가 아님 — task-3000 자신의 롤백 파일이며, 금지 대상인 `20260822T000002_*_ROLLBACK.sql`(task-2996 전용)과는 별개 파일임을 확인 후 접근 안 함).
- 아래 수치는 전부 위 3회 실행의 실제 stdout 을 그대로 옮긴 것.

## 안전장치 — 마이그레이션 파일 자체의 BEGIN/COMMIT 스트리핑

- `20260822T020001_...sql` 파일은 본문 전체를 자체 `BEGIN; ... COMMIT;` 으로 감싸고 있음(파일 67행/318행). 이걸 그대로 `cur.execute(전체파일)` 하면 이미 열려 있는 내 트랜잭션 안에서 **진짜 `COMMIT;`** 이 Postgres 로 전송되어 실제 커밋이 발생하는 대형 사고가 됨.
- `apply_migration_file_in_txn()` 이 파일 앞부분의 주석/공백 라인만 건너뛴 뒤 선두 `BEGIN;` 과 말미 `COMMIT;` 을 정확히 매치해서 제거하고, 제거 후 본문에 `BEGIN|COMMIT|ROLLBACK` 이 더 남아있지 않은지 재검사한 뒤에야 실행. 패턴이 안 맞으면 예외로 즉시 중단(추측 실행 안 함) — 최초 실행 시 실제로 이 경로에서 한 번 걸려서(주석 블록 때문에 `^BEGIN;` 매치 실패) 잡아냈고, 주석 스킵 로직 추가 후 정상 동작 확인.
- 실측: 3회 실행 모두 마지막 줄이 `ROLLBACK executed (outer transaction). No changes persisted to production.` 로 종료.

## 픽스처 버그 발견 및 수정

- 기존(적용 전 재현 때 작성한) `make_fixture_token()` 은 `customer_chat_tokens`/`conversations` 에 바로 INSERT 했는데, `customer_chat_tokens.customer_id` 가 `customers.id` 를 FK 참조함이 실측으로 드러남(`ForeignKeyViolation`). `customers.agent_id`/`conversations.agent_id`/`customer_chat_tokens.agent_id` 는 `pg_constraint` 조회 결과 **FK 없음**(합성 uuid 로 충분). `customers` 테이블은 `agent_id`,`name` 이 NOT NULL — 이 두 컬럼을 채운 `customers` 행을 먼저 INSERT 하도록 수정. 수정 후 A/B/D/C/E 전 그룹이 격리된 픽스처로 정상 동작 확인.
- 이 버그는 이전 회차(`task-3000-morrigan-before.md`)에서는 드러나지 않았음 — 그때는 프로덕션에 유효 토큰이 1건 있어 `find_valid_token()` 경로만 타고 `make_fixture_token()` 폴백은 한 번도 실행되지 않았기 때문. 이번에 그룹별 격리(캡 카운팅 정확성)를 위해 픽스처를 강제로 여러 번 만들면서 처음 노출됨.

## 실측 결과 — `--mode after --apply-migration` (리허설, 35케이스)

- SUMMARY: `mode=after: 35/35 PASS`, exit code 0

### A그룹 — 차단되어야 할 것 (15케이스, 전부 PASS)
- `javascript:alert(1)` → False · `http://fcm.googleapis.com/fcm/send/x`(비-tls) → False · `not-a-url` → False
- userinfo 우회(`...@evil.test`) → False · 쿼리 위장(`?host=fcm...`) → False
- 접미사 위장 `push.apple.com.evil.test` → False · `fcm.googleapis.com.evil.test` → False
- 백슬래시 우회 → False · 포트(`:8080`) → False · 경로 없음(`https://fcm.googleapis.com`) → False
- 개행 삽입(`/x\n`) → False · IDN 호모그래프(키릴 `а` 로 `googleаpis.com` 위장) → False · 순수 유니코드 호스트(한글) → False
- 빈 문자열/공백만/NULL → 전부 False (이건 헬퍼가 아니라 두 RPC 앞단의 `btrim='' ` 체크에서 이미 걸러짐 — 실제 소스로 확인)

### B그룹 — 허용되어야 할 것 (10케이스, 전부 PASS, ★최우선 회귀 확인)
- `fcm.googleapis.com`(정확일치) → True · `android.googleapis.com` → True
- `updates.push.services.mozilla.com`(서브도메인, 접미사매칭) → True · `web.push.apple.com`(접미사매칭) → True · `db5p.notify.windows.com`(접미사매칭) → True
- `push.apple.com` 자체(apex) → True · **대문자 호스트 `FCM.GOOGLEAPIS.COM` → True**(소문자 정규화 확인 — 실제 소스에 `v_host := lower(...)` 존재)
- 보너스(화이트리스트 나머지 3건도 실측): `jmt17.google.com` → True · `updates-autopush.stage.mozaws.net` → True · `updates-autopush.dev.mozaws.net` → True
- **회귀 없음** — 10건 전부 True, "고객 알림이 죽는 회귀" 없음.

### D+C그룹 — 상한(5) + 재등록 (7케이스, 전부 PASS)
- 서로 다른 허용 호스트 endpoint 로 1~5번째 등록 → 전부 True, 6번째(distinct, 상한 초과) → **False** (`active_rows_before_this_attempt=5` 확인)
- 상한 도달 상태에서 **1번째 endpoint 를 그대로 재등록** → **True**(UPDATE 경로는 상한 무관 — 실제 소스에서 `v_updated=0` 인 경우에만 상한 체크 진입함을 확인)
- 최종 활성 행 수 5 (cap=5) — 상한이 실제로 5에서 멈춤

### E그룹 — unsubscribe (2케이스, 전부 PASS)
- `chat_unsubscribe_push` 도 `javascript:alert(3)` → **False** (실제 소스에서 이 RPC 도 `is_allowed_push_endpoint` 를 토큰 해석보다 먼저 호출함을 확인)
- 정상 등록된 endpoint 로 unsubscribe → True (여전히 정상 동작)

## F. 변이(mutation) 봉인 하네스 — `--mode mutation --apply-migration <파일>` 실측

전부 `SAVEPOINT` 안에서 적용 → 영향 케이스 재실행 → `ROLLBACK TO SAVEPOINT` → 함수 sha256 재조회로 원복 확인. 4종 전부 **정확한 실제 소스 문자열**(추측 아님, 마이그레이션 파일에서 그대로 인용)을 대상으로 함.

1. **오리진 검증 제거** — `is_allowed_push_endpoint(text)` 를 시그니처만 동적 조회해 무조건 `RETURN true` 로 교체.
   - 결과: A그룹 15케이스 중 **12케이스가 FAIL** (observed=True, expected=False) — `javascript:`, http, not-a-url, userinfo, 쿼리위장, 접미사위장 2건, 백슬래시, 포트, 경로없음, 개행, IDN 호모그래프 2건 전부 뚫림. 나머지 3케이스(빈문자열/공백/NULL)는 이 헬퍼 호출 전 단계에서 걸러지므로 여전히 PASS(정상) — 헬퍼가 죽어도 그 3건은 안 뚫린다는 사실도 확인됨.
   - `caught_by_tests=True` · `sha256_restored_after_ROLLBACK_TO_SAVEPOINT=True`

2. **접미사 매칭을 LIKE 로 약화** — 실제 소스의 정확 리터럴 `right(v_host, length('push.apple.com') + 1) = '.push.apple.com'` 를 `v_host LIKE ('%' || 'push.apple.com')` 로 치환(점 경계 없는 LIKE).
   - `push.apple.com.evil.test` → 여전히 **False**(정상, 이건 애초에 접미사가 아니라 다른 도메인이라 LIKE 로도 안 걸림)
   - **신규 구멍**: `evilpush.apple.com` → **True**(observed) vs expected=False → **FAIL** — `'evilpush.apple.com' LIKE '%push.apple.com'` 가 실제로 매치되어 뚫림을 실측 확인. 스펙이 정확히 예측한 구멍이 재현됨.
   - `caught_by_tests=True` · `sha256_restored_after_ROLLBACK_TO_SAVEPOINT=True`

3. **상한 제거** — 실제 소스 리터럴 `c_max_active_subs constant integer := 5;` 를 `:= 999999;` 로 치환.
   - D01~D05(1~5번째) 여전히 True(정상), **D06(6번째, distinct)이 True 로 뒤집힘**(expected False) → FAIL. `active_rows_for_conversation_after_D_and_C=6`(cap 무력화로 6행까지 누적) 실측.
   - `caught_by_tests=True` · `sha256_restored_after_ROLLBACK_TO_SAVEPOINT=True`

4. **(보너스, "가능하면" 항목까지 실제 구현) 상한 검사를 UPDATE 경로로도 적용** — advisory lock 직후에 상한 체크를 무조건(INSERT/UPDATE 분기 이전에) 삽입해 재등록도 상한에 걸리게 만듦.
   - D01~D06 은 정상(상한 도달까지의 흐름은 안 바뀜), **C01(상한 도달 상태에서 기존 endpoint 재등록)이 False 로 뒤집힘**(expected True) → FAIL — "정당한 재접속마다 실패" 라는 스펙 우려가 정확히 재현됨.
   - `caught_by_tests=True` · `sha256_restored_after_ROLLBACK_TO_SAVEPOINT=True`

### 변이 요약
`applied=[1,2,3,4] not_applied=[] all_caught=True all_sha_restored=True` — **4종 변이 전부 최소 1개 이상의 케이스를 FAIL 시켰고, 4종 전부 `ROLLBACK TO SAVEPOINT` 후 sha256 이 원래 값으로 복원됨을 실측 확인**. "N passed" 뒤에 이빨이 있음을 실증.

## 참고 — `--mode before` 재확인 (회귀 없음, 35/35 PASS as "취약함이 재현됨")

- 확장된 35케이스 기준으로도 라이브(미패치) 상태는 이전 보고서(`task-3000-morrigan-before.md`)의 6케이스 결론과 완전히 일치: A그룹 12건은 여전히 수용(True, 취약 재현), 빈값 3건은 원래도 차단, D06(6번째)도 상한 없이 True, unsubscribe 의 `javascript:` 도 True. 새로 추가한 IDN 호모그래프·백슬래시·포트·개행·대문자정규화 케이스 등도 pre-fix 로직(비어있지 않은지만 검사)과 일치하는 결과. 코드 정확성의 교차검증으로 사용.

## 결론

- 마이그레이션 `20260822T020001_task3000_push_endpoint_chokepoint.sql` 은 **구문 리허설 통과**(BEGIN/COMMIT 스트리핑 후 트랜잭션 내 무오류 적용), **차단 15/15, 허용 10/10(★회귀 없음), 상한 7/7, unsubscribe 2/2 전부 PASS**.
- 변이 4종(오리진 킬스위치, 접미사 LIKE 약화, 상한 제거, 상한 UPDATE경로 이동) **전부 테스트에 의해 포착됨**, 각 변이 후 함수 정의 sha256 원복 확인 — 프로덕션에는 어떤 변이도 남지 않음.
- 실제 COMMIT 은 한 번도 발생하지 않음(모든 실행 로그 마지막 줄이 ROLLBACK 확인으로 종료).
- 잔여 리스크(테스트 범위 밖, ANU 판단 필요): (a) `chat_unsubscribe_push` 는 endpoint 가 실제로 매치되지 않아도(UPDATE 0행) 항상 `true` 반환 — 반환값만으로 실제 해지 여부를 구분 못 함(백업 원문과 동일한 기존 계약이라 이번 작업 범위 밖으로 판단했으나 기록해 둠). (b) advisory lock 은 같은 `conversation_id` 호출끼리만 직렬화 — 다른 conversation 간 경합은 원래 없음(설계상 정상).

## 산출물
- `/home/jay/projects/InsuRo/.worktrees/task-3000-dev3/tests/task-3000-push-endpoint-probe.py` (35케이스 + 변이 4종 하네스, `--mode {before,after,mutation}` · `--apply-migration` · `--mutation-id` 지원)
- 본 보고서: `/home/jay/workspace/memory/reports/task-3000-morrigan-after.md`

## 추가 보강 (2026-08-22, 2차 — 누락 케이스 + anon 역할 + LIVE 프로덕션 검증)

배경: Codex 반증 리뷰가 Group A 5종 누락을 지적(트레일링 닷/명시적 기본포트/IPv6 리터럴/퓨니코드/접미사 점경계 없음). 마이그레이션이 이미 프로덕션에 2회 적용(멱등) 완료된 상태 — `--mode after`(마이그레이션 재적용 없이)는 이제 실제 라이브 함수를 검증하는 것. 헬퍼 `is_allowed_push_endpoint`의 ACL이 role별 REVOKE로 강화됨(`{postgres=X/postgres}`만 남음)도 대상. 팀장이 지적한 중대 검증 공백: 기존 스위트는 소유자 `postgres` role로 실행되어 SECURITY DEFINER RPC 내부에서 INVOKER 헬퍼를 호출하는 anon 권한 경로가 한 번도 검증되지 않았음.

### 1) Group A 5종 추가 (A16~A20) — 전부 기대값 False
- `A16_trailing_dot` `https://fcm.googleapis.com./fcm/send/x` — 구조 정규식이 `/` 직전을 alnum으로 강제하므로 trailing dot 자체가 구조 단계에서 탈락.
- `A17_explicit_default_port` `https://fcm.googleapis.com:443/fcm/send/x` — `:` 가 호스트 문자셋([A-Za-z0-9.-]) 밖.
- `A18_ipv6_literal` `https://[::1]/x` — `[`,`:` 모두 호스트 문자셋 밖.
- `A19_punycode_lookalike` `https://xn--fcm-googleapis-1234.com/x` — 구조는 통과하지만 화이트리스트 비교에서 탈락.
- ★`A20_suffix_no_dot_boundary` `https://evilpush.apple.com/x` — 가장 중요한 케이스. 실제 헬퍼 소스가 `right(v_host, length('push.apple.com')+1) = '.push.apple.com'` (점 경계 anchored)로 이미 올바르게 구현되어 있어 실측 정상 차단. 점 경계를 빼먹으면(`LIKE '%push.apple.com'`) 뚫린다는 것은 기존 mutation_2 하네스(`M2_new_hole_no_dot_prefix_spoof`)로 이미 실증돼 있었는데, 이번에 "정상 코드가 실제로 이 경계를 갖고 있는가"를 Group A 정규 회귀 케이스로도 못박음.
- 실측(`--mode after --role anon`, 실제 라이브 함수 대상): 5종 전부 PASS.

### 2) anon 역할 실행 보강 — 신규 `--role {postgres,anon}` 플래그, 기본값 `anon`
- 모든 RPC/헬퍼 직접 호출을 `SET LOCAL ROLE <role>` 로 감싸고 성공 시 `RESET ROLE`, 실패/타임아웃 시 `ROLLBACK TO SAVEPOINT` 로 자동 원복(role 잔류 없음). 픽스처 생성(`make_fixture_token`)은 role 스위칭 구간 밖 — 항상 연결 기본 role(postgres, 소유자)에서 실행돼 RLS에 막히지 않음.
- 실측 `python3 tests/task-3000-push-endpoint-probe.py --mode after --role anon` (마이그레이션 재적용 없음 = 실제 라이브 함수 직접 검증): **41/41 PASS**, exit=0. Group A/B/D/C/E/G 전부 anon 역할로 정상 통과.
- 대조 실행 `--role postgres` 병행: 40/40 PASS (G01은 `[SKIP]` — postgres는 애초에 자기 함수에 EXECUTE 권한을 갖고 있어 오라클 차단 검증이 성립하지 않으므로 role≠anon이면 스킵하도록 설계, 이유를 로그에 명시).

### 3) 헬퍼 오라클 차단 케이스 — `G01_helper_not_callable_by_anon`
- `SET LOCAL ROLE anon` 상태에서 `SELECT public.is_allowed_push_endpoint('https://fcm.googleapis.com/x')` 직접 호출 → 실측 `InsufficientPrivilege` 예외 발생 확인. PASS.
- `--mode after` + `--role anon` 조합에서만 실행(그 외 조합은 `[SKIP]`, 사유를 로그에 명시) — before 모드는 헬퍼 자체가 아직 없고, postgres role은 이 오라클 차단 검증 목적과 맞지 않기 때문.

### 4) LIVE 실행 결과 (전부 BEGIN…ROLLBACK, 실측)
- `--mode after --role anon` (마이그레이션 재적용 없음, 실제 라이브 함수 대상): **41/41 PASS**. Group A 21/21, Group B 10/10(★회귀 없음), Group D+C 7/7, Group E 2/2, Group G 1/1.
- `--mode mutation --apply-migration supabase/migrations/20260822T020001_task3000_push_endpoint_chokepoint.sql --role anon` (변이 4종 재실행):
  - 변이1(오리진 킬스위치) → Group A 21건 중 18건 FAIL(관측대로 뚫림 — javascript/http/not-a-url/userinfo/쿼리위장/접미사위장2건/백슬래시/포트/경로없음/개행/IDN호모그래프2건=13건 + 신규 A16~A20 5건, 헬퍼 내부가 무조건 true를 반환하므로 신규 5종도 예외 없이 뚫림). 빈값 3건(A13/14/15)만 헬퍼 호출 전 단계(`btrim`) 검사로 여전히 정상 차단. `caught_by_tests=True`.
  - 변이2(접미사 LIKE 약화) → `M2_new_hole_no_dot_prefix_spoof`(evilpush.apple.com) FAIL로 재현, `M2_still_blocked_dotted_spoof`는 여전히 정상 차단. `caught_by_tests=True`.
  - 변이3(상한 제거) → D06 FAIL로 재현(6번째까지 누적, active_rows=6). `caught_by_tests=True`.
  - 변이4(상한체크 UPDATE경로 이동) → C01 FAIL로 재현(정당한 재등록도 막힘). `caught_by_tests=True`.
  - 4종 전부 `sha256_restored_after_ROLLBACK_TO_SAVEPOINT=True` — 매 변이 직후 함수 sha256이 즉시 원복됨을 실측 재확인.
  - `MUTATION SUMMARY: applied=[1,2,3,4] not_applied=[] all_caught=True all_sha_restored=True`, exit=0.

### 5) 프로덕션 상태 재확인 — post-rollback 검증 (별도 신규 커넥션)
- 스크립트 자신의 트랜잭션이 `ROLLBACK`되고 연결이 닫힌 뒤, 완전히 새로 연 별도 읽기 전용 커넥션으로 재확인:
  - `push_subscriptions` 행 수: **0** (스크립트가 만든 어떤 픽스처도 남지 않음)
  - 함수 sha256 3종 전부 팀장 제시값과 정확히 일치(실측):
    - `chat_register_push_subscription` = `5ebffa2bab5ac5707ef7a95181323ad28e1d7d4c45d9195672a11c9843466ff6` — 일치
    - `chat_unsubscribe_push` = `76e67f663a6516854f33fbbc33834da109b7b479de257ffdbc2388e93067596e` — 일치
    - `is_allowed_push_endpoint` = `e3534677fd686ca5acea22fe84076ce328c55de16b5224ba44014318ef540d06` — 일치
  - 이 검증은 mutation 하네스의 `SAVEPOINT`/`ROLLBACK TO SAVEPOINT` 뿐 아니라 outer `ROLLBACK` 자체가 실제로 아무것도 남기지 않았음을 이중으로 확인한 것 — after/anon, after/postgres, mutation/anon 3회 실행 전부 이 검증까지 PASS.

### 6) statement_timeout / 동시 프로브 안전장치
- 연결 직후 `SET statement_timeout = '30s';` 설정. 로키가 동시에 같은 프로덕션 DB를 프로브 중이었으나, 실행한 3회(after/anon, after/postgres, mutation/anon) 전부 **타임아웃 재시도 0건**(로그에 "TIMEOUT" 문자열 없음). advisory lock은 conversation_id별로 스코프되고 이 스위트는 매번 랜덤 uuid 픽스처를 쓰므로 경합 가능성이 낮다는 설계 근거와 실측이 일치.
- 재시도 로직(`SAVEPOINT`+`ROLLBACK TO SAVEPOINT`, 최대 3회, `psycopg2.errors.QueryCanceled` 캐치)은 구현·코드 경로만 확인했고 실제 타임아웃 발동으로는 검증되지 않음(발동 조건 자체가 이번 실행엔 없었음) — 정직하게 기록.

### 7) Pyright 미사용 변수 정리 (기능 변경 없음)
- `_conv`(튜플 언패킹으로 받았지만 미사용, 4곳: run_group_a/b/e, run_mutation2_affected_cases) → 관용적인 `_`로 정리.
- `run_group_b`, `run_mutation2_affected_cases`의 `mode` 파라미터 — AST로 확인 결과 함수 본문에서 실제로 참조되지 않음 → `_mode`로 이름만 변경. 콜백 통일 시그니처(`(cur, mode, role, log)`)는 존치(mutation 하네스가 4개 러너를 동일 시그니처로 다형 호출하므로) — 위치인자 호출이라 이름 변경은 호출부에 영향 없음.
- `mutation_1_kill_origin_check`/`mutation_2_weaken_suffix_match`/`mutation_3_remove_cap`/`mutation_4_cap_check_on_update_path`의 `log` 파라미터 — 4개 함수 모두 본문에서 `log(...)`를 호출하지 않음(로그는 호출자 `run_mutation`이 반환값을 받아 찍음) → `_log`로 정리.
- 검증: pyright 1.1.408, 별도 임시 폴더에 `pyrightconfig.json`(`typeCheckingMode: basic`, `reportUnusedVariable`/`reportUnusedImport: warning`)을 두고 독립 실행 → **0 errors, 0 warnings, 0 informations**. (참고: 저장소 기본 `pyrightconfig.json`은 `include: ["server/**/*.py"]`뿐이라 이 테스트 파일은 원래 스캔 범위 밖 — 그래도 별도 config로 직접 검사해 정리 완료.)
- 부수적으로 신규 코드(`verify_post_rollback()`/`main()`의 `cur.fetchone()[0]` 직접 언패킹)에서 새로 발생한 `reportOptionalSubscript`/`reportGeneralTypeIssues` 2건도 잡아 None 가드 추가로 해소(psycopg2 번들 스텁상 `fetchone()`이 `Optional` 반환이라 발생 — 다른 함수들은 `cur`가 함수 파라미터라 타입 추론이 Unknown이라 원래 안 걸렸던 것으로, 신규로 로컬 `cur = conn.cursor()`를 쓴 두 곳에서만 나타남).

### 8) 실행 커맨드 (재현용, 값은 마스킹)
- `python3 tests/task-3000-push-endpoint-probe.py --mode after --role anon`
- `python3 tests/task-3000-push-endpoint-probe.py --mode after --role postgres` (대조군)
- `python3 tests/task-3000-push-endpoint-probe.py --mode mutation --apply-migration supabase/migrations/20260822T020001_task3000_push_endpoint_chokepoint.sql --role anon`

### 갱신된 결론
- 총 케이스 수: **41개** — 실행 로그 라인을 그룹 헤더 기준으로 직접 재집계(수동 카운트, awk로 실측): Group A=21(A01~A11 11건 + A12a/A12b 2건 + A13~A15 3건 + 신규 A16~A20 5건), Group B=10, Group D+C=7(D01~D06 6건 + C01 1건), Group E=2, Group G=1. 21+10+7+2+1=41 — 스크립트 자체 `SUMMARY` 라인의 `41/41 PASS`와 정확히 일치.
- `--mode after --role anon` 라이브 실행: **41/41 PASS** (Group B 회귀 0건, Group G 오라클 차단 확인, exit=0).
- `--mode mutation --role anon` (변이 4종): **4/4 전부 포착(caught=True), 4/4 전부 sha256 원복 확인**, exit=0.
- 프로덕션 `push_subscriptions` 행 수 0, 함수 sha256 3종 전부 팀장 제시값과 일치 — 실측 재확인 완료(3회 실행 모두).
- Pyright 미사용 변수(`_conv`/`mode`/`log`) 정리 완료 — 3회 실행 결과가 정리 전후 동일함으로 기능 회귀 없음 확인.
- Group B(정상 경로) 및 다른 어떤 그룹에서도 FAIL 0건 — 팀장에게 긴급 보고할 회귀 없음.

## 추가 보강 (2026-08-22, 3차 — 길이 상한 케이스, 로키 레드팀 후속)

배경: 로키가 매우 긴 `p_endpoint`에서 함수가 boolean 대신 처리되지 않은 예외(`ProgramLimitExceeded`, btree 인덱스 한계 ~2704바이트)를 던지는 계약 위반을 발견. 루가 헬퍼에 `c_max_endpoint_len constant integer := 2048;` 길이 상한을 NULL 체크 직후·정규식 직전에 추가(마이그레이션 파일 실측 확인, 이미 워킹트리에 반영돼 있었음). ★로키의 229,895자 임계값은 **반복 문자열이라 TOAST 압축이 먹은** 경우이므로, 이번 케이스는 전부 `random.Random(seed)` 기반 **비반복 랜덤 ASCII([!-~] 범위, RPC 자체 경로 정규식과 동일 문자셋)**로 구성 — 압축이 거의 안 먹혀 논리 길이와 실제 파괴 지점이 근접하도록 설계.

### 1) 추가한 케이스 (5건, 총 41 → 46)
- Group A(길이) 3건, 전부 기대값 **False** — 예외가 나면 그 자체로 FAIL:
  - `A21_over_length_2049` — 허용 호스트(`https://fcm.googleapis.com/`) + 랜덤 경로로 총 길이 정확히 2049자(상한+1)
  - `A22_over_length_non_compressible_8k` — 동일 호스트 + 비반복 랜덤 경로로 총 8192자
  - `A23_over_length_300k` — 동일 호스트 + 비반복 랜덤 경로로 총 300,000자(로키 원 재현 자릿수)
  - 구현: 기존 `run_group_a`(plain `call_register`)로는 예외가 그대로 전파되어 스위트 전체가 죽으므로, 신규 `call_register_expect_no_exception()`(`psycopg2.Error`를 잡아 `("error", 예외클래스명)`으로 반환) + `run_group_a_length()` 러너를 별도로 만들어 예외를 "관측된 FAIL"로 안전하게 흡수하도록 구현. `call_register()` 내부의 `call_with_savepoint_retry()`가 예외 시 이미 자신의 내부 SAVEPOINT로 `ROLLBACK TO SAVEPOINT` 하므로 트랜잭션은 항상 사용 가능한 상태로 남음(추가 SAVEPOINT 불필요, 실측으로 확인 — 이후 케이스들도 정상 이어서 실행됨).
- Group B(경계) 2건, 전부 기대값 **True** — `GROUP_B_CASES`에 직접 추가(예외 우려 없어 기존 `run_group_b` 그대로 사용):
  - `B11_at_length_limit_2048` — 허용 호스트 + 랜덤 경로로 총 길이 **정확히 2048자**(경계 자체는 안 막혀야 함)
  - `B12_realistic_long_wns_900` — `https://db5p.notify.windows.com/w/?token=<랜덤 859자>` 총 900자(WNS 스타일 실사용 최장 관측치 근사)

### 2) 실측 — 리허설 (`--mode after --role anon --apply-migration supabase/migrations/20260822T020001_task3000_push_endpoint_chokepoint.sql`, 트랜잭션 내 적용→롤백)
- `SUMMARY mode=after role=anon: 46/46 PASS`, exit=0
- Group A(길이) 3/3 PASS: A21/A22/A23 전부 `observed=False`(예외 없음 — 길이 상한이 헬퍼 초입에서 먼저 걸려 `ProgramLimitExceeded`까지 도달하지 않음)
- Group B(경계) 2/2 PASS: B11(정확히 2048자) `observed=True`, B12(WNS ~900자) `observed=True` — 경계에서 정상 endpoint 를 막지 않음, 실사용 최장 관측치도 여유 있게 통과
- post-rollback 검증(별도 신규 커넥션): `push_subscriptions` 행 수=0, 함수 sha256 3종 전부 기존 `EXPECTED_SHA256`(팀장 제시, 라이브 값)와 정확히 일치 — 리허설이 실제로 아무것도 커밋하지 않았고, **라이브가 아직 이 픽스(길이 상한)를 갖고 있지 않음**을 sha 불변으로 재확인.

### 3) 실측 — LIVE (`--mode after --role anon`, `--apply-migration` 없음 = 재적용 대기하지 않고 실제 라이브 함수 직접 검증)
- `SUMMARY mode=after role=anon: 43/46 PASS` — **길이 3케이스만 FAIL, 나머지 43/43 PASS**(회귀 없음, Group B 신규 B11/B12 포함 전부 True)
- `!!! CRITICAL: Group A (length) regressions — over-length p_endpoint did not return false (raised instead, or the cap is not enforced): A21_over_length_2049, A22_over_length_non_compressible_8k, A23_over_length_300k`
- 개별 관측값(라이브, 픽스 미적용 상태):
  - `A21_over_length_2049` → `observed=True`(예외는 없었음 — 2049바이트 비반복 콘텐츠는 아직 btree ~2704바이트 한계 아래라 INSERT 자체는 성공, 그러나 상한 로직이 없어 조용히 등록됨. "예외는 아니지만 여전히 계약 위반" 케이스로 실측됨)
  - `A22_over_length_non_compressible_8k` → `observed='EXCEPTION(ProgramLimitExceeded)'`
  - `A23_over_length_300k` → `observed='EXCEPTION(ProgramLimitExceeded)'`
  - 로키가 지적한 정확히 그 예외(`ProgramLimitExceeded`)가 A22/A23에서 실측 재현됨 — **이 테스트에 이빨이 있다는 증거**(라이브에서 FAIL하는 게 정상, 픽스 재적용 후 다시 돌리면 46/46이 되어야 함)
- Group B 10+2=12건 전부 PASS(회귀 0건), Group D+C 7/7, Group E 2/2, Group G 1/1 — 길이 케이스 외 어떤 회귀도 없음
- post-rollback 검증: 두 실행(리허설/라이브) 전부 `push_subscriptions` 행 수=0, sha256 3종 일치 — 실제 프로덕션 COMMIT 없음 재확인.

### 4) Pyright 정리 (기능 변경 없음)
- 명령: `pyright tests/task-3000-push-endpoint-probe.py` (pyright 1.1.408). 저장소 기본 `pyrightconfig.json`은 `include: ["server/**/*.py"]`뿐이라 이 파일은 원래 스캔 범위 밖 — 파일 경로를 직접 지정해 강제로 분석.
- 실제로 제거 가능했던 미사용 파라미터 2곳을 시그니처에서 **제거**(단일 호출부만 있어 다형 디스패치와 무관):
  - `run_group_b(cur, _mode, role, log)` → `run_group_b(cur, role, log)` (호출부 `main()`도 함께 수정)
  - `mutation_1_kill_origin_check`/`mutation_2_weaken_suffix_match`/`mutation_3_remove_cap`/`mutation_4_cap_check_on_update_path`의 `_log` 파라미터 4곳 전부 제거 → 시그니처 `(cur)`만 남김, 호출부 `run_mutation()`의 `mutation_fn(cur, log)` → `mutation_fn(cur)`로 수정
- `run_mutation2_affected_cases(cur, _mode, role, log)`의 `_mode`는 **의도적으로 존치**: `MUTATIONS` 딕셔너리를 통해 `run_group_a`/`run_group_d_and_c`(둘 다 `mode`를 실제로 사용)와 동일한 `(cur, mode, role, log)` 다형 시그니처로 `run_mutation()`이 통일 호출하므로, 이 함수만 파라미터를 제거하면 그 호출부가 깨짐. 언더스코어 접두사 + 기존 주석으로 "의도적 미사용"임을 이미 명시 중.
- 검증: 정리 후 `pyright tests/task-3000-push-endpoint-probe.py` → **0 errors, 0 warnings, 0 informations**. (참고: 정리 전에도 이 파일 자체에서는 pyright가 0경고를 보고했음 — 언더스코어 접두사 관례와 pyright의 기본 basic 모드가 파라미터/튜플언패킹 미사용을 별도로 경고하지 않기 때문. 그래도 지시대로 실제 제거 가능한 곳은 제거해 코드 자체를 정리함.)
- 신규 추가 코드(`_random_path`/`_endpoint_of_total_length`/`_wns_realistic_boundary_endpoint`/`call_register_expect_no_exception`/`run_group_a_length`/`GROUP_A_LENGTH_CASES`)도 동일 실행에서 0경고 확인.

### 5) 총 케이스 수 재집계
- 46개: Group A=21 + Group A(길이)=3 + Group B=12(기존10+B11/B12) + Group D+C=7 + Group E=2 + Group G=1. 리허설 `SUMMARY` 라인 `46/46 PASS`, 라이브 `SUMMARY` 라인 `43/46 PASS`와 정확히 일치(스크립트 자체 집계, 수동 카운트 아님).

### 결론 (3차 보강)
- 리허설(트랜잭션 내 픽스 적용): **46/46 PASS** — 길이 상한 3케이스 전부 정상 차단(예외 없이 False), 경계/실사용 2케이스 전부 정상 통과(True).
- 라이브(픽스 미적용, 현재 프로덕션 상태): **43/46 PASS**, 길이 3케이스만 예상대로 FAIL(2건은 로키가 지적한 `ProgramLimitExceeded` 그대로 재현, 1건은 예외 없이 조용히 통과 — 둘 다 계약 위반이므로 FAIL로 정확히 기록됨). Group B(정상 경로)는 라이브·리허설 양쪽 모두 12/12 PASS로 회귀 0건.
- 프로덕션 COMMIT 없음(2회 실행 모두 post-rollback 검증 PASS). `DROP POLICY`/`CREATE POLICY`, `20260822T000002_*_ROLLBACK.sql`, 마이그레이션 SQL 수정 — 전부 미실행/미접근.
- Pyright 0 warnings 확인 완료(기능 변경 없음).
- Group B(정상 경로) 양쪽 실행 모두 FAIL 0건 — 팀장에게 긴급 보고할 회귀 없음. 팀장이 프로덕션에 재적용 후 `--mode after --role anon`(마이그레이션 재적용 없이) 재실행 시 46/46이 되어야 함(현재는 43/46이 정상 기대값).

## 최종 마무리 (2026-08-22, 4차 — 길이 상한 프로덕션 적용 완료 후 sha256 상수 갱신 + 재실행)

배경: 팀장이 길이 상한(2048) 포함 버전을 프로덕션에 적용 완료. 그 결과 `is_allowed_push_endpoint` 정의 자체가 바뀌었고, 3차 보강 시점에 박아둔 `EXPECTED_SHA256["is_allowed_push_endpoint"]`(길이 상한 이전 값 `e3534677…`)가 노후화되어 `--mode after --role anon` 자체는 46/46 PASS인데 post-rollback sha256 검증만 단독 FAIL하는 상태로 발견됨. 팀장이 준 신규 값(`78b4f328…`)을 그대로 믿지 않고 프로덕션을 직접 재조회해 확인 후 반영.

### 1) 프로덕션 실조회로 확인한 sha256 3종
- 조회 방법: 지정된 쿼리(`SELECT encode(sha256(convert_to(pg_get_functiondef(p.oid),'UTF8')),'hex') FROM pg_proc p JOIN pg_namespace n ON n.oid=p.pronamespace WHERE n.nspname='public' AND p.proname='is_allowed_push_endpoint'`)를 별도 read-only 연결로 직접 실행, 그리고 기존 하네스의 `get_regproc_source()`/`get_regprocedure_source()` 경로로도 독립 재조회 — 두 경로 결과가 정확히 일치함을 확인.
- `is_allowed_push_endpoint` = `78b4f328e1ea9b8116318e42b7119f8b639fb9a16ba79b4fd3d74e5497cee8d2` — 팀장이 제시한 값과 **정확히 일치**(실측 확인, 그대로 믿지 않고 직접 조회 완료).
- `chat_register_push_subscription` = `5ebffa2bab5ac5707ef7a95181323ad28e1d7d4c45d9195672a11c9843466ff6` — 기존 `EXPECTED_SHA256`와 **동일, 미변경 확인**.
- `chat_unsubscribe_push` = `76e67f663a6516854f33fbbc33834da109b7b479de257ffdbc2388e93067596e` — 기존 `EXPECTED_SHA256`와 **동일, 미변경 확인**.
- 다른 두 RPC 는 바뀌지 않았음이 실측으로 확인됨 — 팀장이 우려한 "다른 RPC 도 바뀌었으면 즉시 보고" 조건에 해당 없음.
- `tests/task-3000-push-endpoint-probe.py`의 `EXPECTED_SHA256["is_allowed_push_endpoint"]`를 `78b4f328…`로 갱신하고, 상수 옆(딕셔너리 항목 및 상단 블록 주석)에 "이 값은 길이 상한(2048, `c_max_endpoint_len`) 포함 버전 기준"이라는 근거·조회방법·재확인 결과를 명시하는 주석을 남김. 변이 하네스(`run_mutation`)의 원복 확인은 `current_sha_snapshot()`으로 실행 전/후 자기 비교라 `EXPECTED_SHA256`을 쓰지 않음 — 이 상수를 쓰는 곳은 `verify_post_rollback()` 단 한 곳뿐임을 grep으로 확인, 갱신 누락 지점 없음.

### 2) 최종 재실행 3종 (전부 BEGIN…ROLLBACK, COMMIT 없음)
- `--mode after --role anon`: **46/46 PASS**, exit=0. Group A 21/21, Group A(길이) 3/3, Group B 12/12(★회귀 없음), Group D+C 7/7, Group E 2/2, Group G 1/1. post-rollback 검증 3항목(행수 0, sha256 3종) **전부 PASS** — 3차 보강 때 남아있던 유일한 FAIL(post-rollback sha256 `is_allowed_push_endpoint`)이 해소됨.
- `--mode after --role postgres` (대조군): **45/45 PASS**, exit=0. G01은 `[SKIP]`(postgres role에서는 오라클 차단 검증이 성립하지 않음, 설계대로). post-rollback 검증 전부 PASS.
- `--mode mutation --apply-migration supabase/migrations/20260822T020001_task3000_push_endpoint_chokepoint.sql --role anon`: exit=0. `MUTATION SUMMARY: applied=[1,2,3,4] not_applied=[] all_caught=True all_sha_restored=True`. post-rollback 검증 전부 PASS.

### 3) 변이 4종 결과 (상세)
- 변이1(오리진 킬스위치, `is_allowed_push_endpoint`를 무조건 `RETURN true`로 교체): Group A 21건 중 **18건 FAIL**(observed=True, expected=False) — A01~A12b 중 13건 + 신규 A16~A20 5건 전부 뚫림. 빈값 3건(A13/A14/A15)은 헬퍼 호출 이전 단계 검사라 여전히 PASS. `caught_by_tests=True`, `sha256_restored_after_ROLLBACK_TO_SAVEPOINT=True`.
- 변이2(`push.apple.com` 접미사 매칭을 점 경계 없는 LIKE로 약화): `M2_still_blocked_dotted_spoof` PASS(여전히 차단), `M2_new_hole_no_dot_prefix_spoof`(`evilpush.apple.com`) **FAIL**(observed=True, expected=False) — 스펙이 예측한 구멍 그대로 재현. `caught_by_tests=True`, `sha256_restored_after_ROLLBACK_TO_SAVEPOINT=True`.
- 변이3(`c_max_active_subs`를 5→999999로 상한 무력화): D01~D05, C01 PASS, `D06_cap_6th_distinct_over_limit` **FAIL**(observed=True, expected=False, `active_rows_for_conversation_after_D_and_C=6`). `caught_by_tests=True`, `sha256_restored_after_ROLLBACK_TO_SAVEPOINT=True`.
- 변이4(상한 체크를 UPDATE 경로에도 걸리도록 이동): D01~D06 PASS, `C01_reregister_existing_endpoint_at_cap` **FAIL**(observed=False, expected=True) — 정당한 재등록이 막히는 회귀가 예측대로 재현. `caught_by_tests=True`, `sha256_restored_after_ROLLBACK_TO_SAVEPOINT=True`.
- 4종 전부 최소 1건 이상 FAIL을 발생시켜 포착됨(`caught=True`), 4종 전부 `ROLLBACK TO SAVEPOINT` 직후 함수 sha256 원복 확인(`sha_restored=True`) — 변이 하네스 자체는 이번 sha256 상수 갱신과 무관하게(자기 전/후 비교 방식이라) 처음부터 정상 동작했었고, 이번 재실행으로 갱신된 상수 기준으로도 동일하게 재확인됨.

### 4) Pyright 잔여 경고
- 대상: `_mode`(현재 파일 835행, `run_mutation2_affected_cases`), `_id`/`_obs`/`_exp`(현재 파일 1006행·1176행, 각각 `run_mutation()`과 `main()`의 pass/fail 집계 제너레이터 표현식).
- 실측: `pyright tests/task-3000-push-endpoint-probe.py` (basic 모드, 기본 설정) → **0 errors, 0 warnings, 0 informations**. `reportUnusedVariable: warning`을 명시적으로 켠 별도 config로도 재확인 → 동일하게 0건. 최소 재현 스니펫(동일 패턴의 미사용 파라미터/튜플언패킹 변수)으로 별도 테스트한 결과, pyright는 함수 파라미터의 "미사용"이나 for-루프/제너레이터의 튜플 언패킹 변수 미사용에 대해 애초에 경고를 내지 않음(언더스코어 접두사 관례와 무관하게 구조적으로 미탐지) — `strict` 모드에서는 이 두 지점에 `reportUnknownParameterType`/`reportUnknownVariableType`류 진단이 뜨지만, 이는 "미사용" 경고가 아니라 파일 전체(수백 곳, `cur`/`role`/`log` 등 거의 모든 매개변수)에 걸친 타입 미표기 지적이라 이번 지시("_mode/_id/_obs/_exp" 콕 집은 잔여 경고) 범위와 다름 — 별도 대규모 타입 주석 작업 없이는 해소 불가하고 기능도 안 바뀌므로 손대지 않음.
- 그럼에도 지시대로 실제 정리 가능한 지점은 정리함(기능 변경 없음, 실행 3종 결과가 정리 전/후 동일함으로 회귀 없음 확인):
  - `run_mutation()`(1006행 부근)과 `main()`(1176행 부근)의 `for (_id, _obs, _exp, passed) in ...` → `for (*_, passed) in ...`로 변경 — 4-튜플 중 마지막 `passed`만 실제로 쓰이므로 앞 3개를 이름 없는 단일 `*_`로 흡수, 동작 동일(회귀 없음, 재실행 3종 결과로 확인).
  - `run_mutation2_affected_cases(cur, _mode, role, log)`의 `_mode`는 **제거 불가로 판단해 존치**: `MUTATIONS` 딕셔너리가 이 함수를 `run_group_a`/`run_group_d_and_c`(둘 다 `mode`를 실제로 사용)와 동일한 `(cur, mode, role, log)` 위치인자 시그니처로 `run_mutation()`에서 다형 호출하므로, 이 함수만 파라미터를 빼면 그 호출부 arity가 깨짐. 함수 docstring에 이 이유를 명시적으로 보강(공용 디스패치 시그니처 때문에 제거 불가).
  - 편집 후 `python3 -c "import ast; ast.parse(...)"` 로 구문 확인, `pyright` 재실행으로 0/0/0 유지 확인.

### 5) 결론
- `EXPECTED_SHA256["is_allowed_push_endpoint"]`를 프로덕션 실조회값(`78b4f328…`, 팀장 제시값과 일치 확인)으로 갱신 완료, 다른 두 RPC sha256은 미변경 확인, 상수 옆 근거 주석 추가 완료.
- 최종 재실행 3종 전부 성공: `after/anon` 46/46 PASS(post-rollback sha256 포함 전부 PASS, 3차 보강 때의 유일한 FAIL 해소), `after/postgres` 45/45 PASS(대조군), `mutation/anon` 4종 전부 caught=True·sha_restored=True.
- 프로덕션 COMMIT 없음(3회 실행 모두 `ROLLBACK executed` + post-rollback 검증 PASS로 재확인). 마이그레이션 SQL 미수정, `DROP POLICY`/`CREATE POLICY` 미실행, `20260822T000002_*_ROLLBACK.sql` 미접근.
- Pyright: 정리 전후 모두 0 warnings(파일 자체는 원래도 0건이었음 — 상세 사유 위 4)절 참조). 실제 제거 가능했던 2곳(`_id/_obs/_exp` 튜플 언패킹)은 `*_`로 정리, 1곳(`_mode`)은 공용 디스패치 시그니처 때문에 제거 불가로 판단해 존치하고 사유를 docstring에 명시.
- FAIL 남은 것 없음 — 위 3종 실행 전부 all-PASS(mutation은 "FAIL이 곧 성공 신호"인 변이 내부 케이스 제외).
