# task-2999 DB 파트 실측 증거 (루/Lugh, 개발3팀)

- 대상: Supabase 프로덕션 `postgres` @ `aws-1-ap-northeast-2.pooler.supabase.com:5432` (DB `postgres`)
- 워크트리: `/home/jay/projects/InsuRo-worktrees/task-2999-dev3`
- 비밀번호는 `/home/jay/projects/InsuRo/.env` 에서 런타임 파싱하며 로그/파일/보고서 어디에도 출력하지 않았다.
- 접속 계정 `postgres` 는 `rolbypassrls = True` 이므로, 모든 취약점/RPC 프로브는
  `SET LOCAL ROLE anon` 으로 **anon 롤로 강등한 뒤** 실행했다.
- 프로덕션에 남긴 유일한 변경은 4단계(1단계 마이그레이션 COMMIT)이며,
  나머지 프로브는 전부 `BEGIN ... ROLLBACK` 안에서 실행했다.
- 프로브 스크립트: `/tmp/task2999_conn.py`, `/tmp/task2999_step{1_backup,2_probe,2b_probe,4_apply,5_rpc,6_agent,6b_global}.py` (리포 미커밋)

> 주의(정직 기록): 증거 재수집을 위해 `step1_backup` 을 4단계 적용 **이후** 한 번 더 실행하여
> `_backup/task-2999_pg_policies_before.json` 이 재생성되었다. 다만 최초(적용 전) 덤프와
> 재생성본이 내용상 동일함은 6-2 절의 전역 diff(적용 전 덤프 대비 added=0/removed=0/changed=0, 190행 동일)로
> 이미 입증되어 있다. 1단계는 정책을 전혀 건드리지 않으므로 두 덤프는 동일하다.

---

## 1. 백업 — 적용 전 pg_policies 전량 덤프

```
$ python3 /tmp/task2999_step1_backup.py
TOTAL pg_policies rows dumped: 190
OUT: /home/jay/projects/InsuRo-worktrees/task-2999-dev3/supabase/migrations/_backup/task-2999_pg_policies_before.json

=== push_subscriptions policies (human readable) ===
count: 6
---
  policyname : Agents can insert push subs
  cmd        : INSERT
  roles      : ['authenticated']
  permissive : PERMISSIVE
  qual       : None
  with_check : '(conversation_id IN ( SELECT conversations.id\n   FROM conversations\n  WHERE (conversations.agent_id = auth.uid())))'
---
  policyname : Agents can read own push subs
  cmd        : SELECT
  roles      : ['authenticated']
  permissive : PERMISSIVE
  qual       : '(conversation_id IN ( SELECT conversations.id\n   FROM conversations\n  WHERE (conversations.agent_id = auth.uid())))'
  with_check : None
---
  policyname : Agents can update own push subs
  cmd        : UPDATE
  roles      : ['authenticated']
  permissive : PERMISSIVE
  qual       : '(conversation_id IN ( SELECT conversations.id\n   FROM conversations\n  WHERE (conversations.agent_id = auth.uid())))'
  with_check : None
---
  policyname : Anon can insert push subs
  cmd        : INSERT
  roles      : ['anon']
  permissive : PERMISSIVE
  qual       : None
  with_check : "(subscriber_type = 'customer'::text)"
---
  policyname : Anon can read push subs
  cmd        : SELECT
  roles      : ['anon']
  permissive : PERMISSIVE
  qual       : "(subscriber_type = 'customer'::text)"
  with_check : None
---
  policyname : Anon can update push subs
  cmd        : UPDATE
  roles      : ['anon']
  permissive : PERMISSIVE
  qual       : "(subscriber_type = 'customer'::text)"
  with_check : "(subscriber_type = 'customer'::text)"

=== push_subscriptions row count ===
push_subscriptions rows: 0
conversations rows: 1
customer_chat_tokens rows: 1
customer_chat_tokens active rows: 1
customers rows: 7
```

산출 파일: `supabase/migrations/_backup/task-2999_pg_policies_before.json` (190행, 전 스키마/전 테이블)

---

## 2. 기준선 프로브 — 취약점 직접 재현 (전부 BEGIN ... ROLLBACK)

`conversations` 는 1행 존재하므로 실존 대화 `62910d97-84e0-4438-8a31-284cc71f4690`
(소유 agent_id=`da3539b3-f076-46d4-a2fb-547d35f95930`)를 타인 소유 대화로 사용했다.
`push_subscriptions` 는 0행이므로, 읽기/덮어쓰기 프로브는 같은 트랜잭션 안에서
피해자 행을 postgres 로 seed 한 뒤 anon 으로 공격하고 ROLLBACK 했다.

```
$ python3 /tmp/task2999_step2_probe.py
### 2-0. current_user / rls context
('postgres', 'postgres', True)

### 2-0b. push_subscriptions columns
  id | uuid | nullable=NO | default=gen_random_uuid()
  conversation_id | uuid | nullable=NO | default=None
  endpoint | text | nullable=NO | default=None
  p256dh | text | nullable=NO | default=None
  auth | text | nullable=NO | default=None
  subscriber_type | text | nullable=NO | default='customer'::text
  subscribed_at | timestamp with time zone | nullable=NO | default=now()
  expires_at | timestamp with time zone | nullable=NO | default=(now() + '24:00:00'::interval)
  is_active | boolean | nullable=NO | default=true
  created_at | timestamp with time zone | nullable=NO | default=now()

### 2-0c. RLS enabled?
  relrowsecurity, relforcerowsecurity = (True, False)

### 2-1. pick existing conversation
   ('62910d97-84e0-4438-8a31-284cc71f4690', 'da3539b3-f076-46d4-a2fb-547d35f95930', 'a121de5d-e240-4565-9ae7-34e62ac24d24', datetime.datetime(2026, 4, 29, 1, 20, 31, 649324, tzinfo=datetime.timezone.utc))
  -> chosen conversation_id=62910d97-84e0-4438-8a31-284cc71f4690 (owner agent_id=da3539b3-f076-46d4-a2fb-547d35f95930)

### 2-2. anon INSERT into OTHER's conversation
SQL: INSERT INTO public.push_subscriptions(conversation_id, endpoint, p256dh, auth, subscriber_type)
         VALUES (%s, 'https://probe.invalid/dagda-2999', 'p', 'a', 'customer') RETURNING id, conversation_id
RESULT: rowcount=1 fetched=[('3bb646d5-59f3-439b-aefb-468813dd88c8', '62910d97-84e0-4438-8a31-284cc71f4690')]
VERDICT: SUCCEEDED (vulnerability reproduced)
(ROLLBACK done)

### 2-3. anon SELECT all customer push subs
SQL: SELECT id, conversation_id, endpoint, p256dh, auth FROM public.push_subscriptions WHERE subscriber_type='customer'
RESULT: rowcount=0 fetched=[]
VERDICT: SUCCEEDED (vulnerability reproduced)
(ROLLBACK done)

### 2-4. anon UPDATE (overwrite other's subscription)
NOTE: push_subscriptions has 0 rows, so a victim row is seeded as postgres INSIDE the same transaction, then ROLLBACK.
  seeded victim row id=90bfea58-e4a2-4a9b-927e-c268e78154d1 (as postgres, will be rolled back)
  anon SELECT of victim row -> [('90bfea58-e4a2-4a9b-927e-c268e78154d1', 'https://victim.example/endpoint', 'VICTIM_P256DH', 'VICTIM_AUTH')]
  anon UPDATE -> rowcount=1 result=[('90bfea58-e4a2-4a9b-927e-c268e78154d1', 'https://attacker.invalid/hijack', 'ATTACKER_P256DH', 'ATTACKER_AUTH')]
  VERDICT: anon overwrite SUCCEEDED (vulnerability reproduced)
  (ROLLBACK done)

### 2-5. anon DELETE (control: no DELETE policy expected)
SQL: DELETE FROM public.push_subscriptions WHERE subscriber_type='customer'
RESULT: rowcount=-1 (no rows returned)
VERDICT: SUCCEEDED (vulnerability reproduced)
(ROLLBACK done)

### 2-6. post-ROLLBACK integrity check
  push_subscriptions rows AFTER all probes: 0
  probe-artifact rows remaining: 0
  conversations rows: 1
```

빈 테이블 때문에 판정이 불가능했던 SELECT/DELETE 를 seed 행으로 재프로브:

```
$ python3 /tmp/task2999_step2b_probe.py

### 2-3b. anon SELECT of OTHER's subscription (seeded victim row)
SQL(as anon): SELECT id, conversation_id, endpoint, p256dh, auth FROM public.push_subscriptions WHERE id=%s
RESULT: rowcount=1 fetched=[('3572bac5-fd90-469e-8115-4cb6ee0f4cc7', '62910d97-84e0-4438-8a31-284cc71f4690', 'https://victim.example/endpoint', 'VICTIM_P256DH', 'VICTIM_AUTH')]
VERDICT: SUCCEEDED (vulnerability reproduced)
(ROLLBACK done)

### 2-5b. anon DELETE of OTHER's subscription (seeded victim row)
SQL(as anon): DELETE FROM public.push_subscriptions WHERE id=%s
RESULT: rowcount=0 fetched=None
VERDICT: NO ROWS AFFECTED (blocked by RLS filtering, silently)
(ROLLBACK done)

push_subscriptions rows AFTER: 0
```

### 기준선 판정 요약

| 프로브 (anon 롤) | 결과 | 취약 여부 |
|---|---|---|
| 타인 conversation_id 로 INSERT | rowcount=1, 행 생성됨 | **재현됨 (IDOR)** |
| 타인 구독 SELECT (endpoint/p256dh/auth 노출) | rowcount=1, 키 전부 노출 | **재현됨 (IDOR)** |
| 타인 구독 UPDATE (endpoint/키 덮어쓰기 = 푸시 탈취) | rowcount=1, 값 교체됨 | **재현됨 (IDOR)** |
| 타인 구독 DELETE | rowcount=0 (anon DELETE 정책 부재 → 조용히 0행) | 차단 (대조군) |

ROLLBACK 후 `push_subscriptions` 행 수 = **0** (기준선 그대로), 프로브 잔여행 = 0.

---

## 3. 1단계 마이그레이션 SQL 작성 (RPC 만, 정책 DROP 없음)

생성 파일 2개:

- `supabase/migrations/20260822T010000_task2999_push_rpc_step1_ROLLBACK.sql` (먼저 정렬, 58행)
- `supabase/migrations/20260822T010001_task2999_push_rpc_step1.sql` (적용, 223행 / 7068 bytes)

적용본은 기존 `20260822T000003_task2996_push_subscriptions_caller_binding.sql` 의
`-- ── 2)` 섹션부터 `COMMIT;` 직전까지를 **기계적으로 잘라내** 만들었다(손으로 옮겨 적지 않음).

### 함수 본문 문자 단위 동일성 검증

```
$ python3 - (비교 스크립트)
OLD block: 4028 chars  sha256=9042db343591f55da8f4a9113a7c6e76b4ec5b50e47b93c9d750210e53e53be0
NEW block: 4028 chars  sha256=9042db343591f55da8f4a9113a7c6e76b4ec5b50e47b93c9d750210e53e53be0
IDENTICAL (byte-for-byte): True

function defs found: OLD=2 NEW=2
  [0] chat_register_push_subscription     len=1782 sha256=832c97a32d2437e9  identical=True
  [1] chat_unsubscribe_push               len=980 sha256=bcf8f106b6cafa64  identical=True

OLD GRANT/REVOKE/INDEX lines (6):
   REVOKE ALL ON FUNCTION public.chat_register_push_subscription(text, text, text, text) FROM PUBLIC;
   GRANT EXECUTE ON FUNCTION public.chat_register_push_subscription(text, text, text, text) TO anon;
   REVOKE ALL ON FUNCTION public.chat_unsubscribe_push(text, text) FROM PUBLIC;
   GRANT EXECUTE ON FUNCTION public.chat_unsubscribe_push(text, text) TO anon;
   CREATE INDEX IF NOT EXISTS idx_push_subscriptions_conv_endpoint_type
     ON public.push_subscriptions(conversation_id, endpoint, subscriber_type);

NEW GRANT/REVOKE/INDEX lines (6):
   (위와 동일 — 6줄 모두 일치)
```

### DROP POLICY / CREATE POLICY 부재 확인

```
$ grep -n 'POLICY' <신규 2파일> | grep -v ':--'
  NONE (no non-comment POLICY statement)
```
→ 신규 2파일 어디에도 주석 아닌 `POLICY` 문이 없다. (주석 안에서 3단계 절차를 설명할 때만 등장)

### 헤더 주석에 명시한 사항

- 3단계 중 **1단계**이며 anon 정책 3건 DROP 을 포함하지 않음
- 2단계 = 프론트 PR 머지 + 배포(ANU), 3단계 = 정책 DROP(ANU)
- `20260822T000003` 은 3단계용으로 존치, 1단계 적용 후 ANU 가 그 파일의 **DROP POLICY 3줄만** 실행
- 정렬 순서 근거: 순수 추가(additive)라 ROLLBACK 이 먼저 정렬돼도 부분 재생의 최종 상태는
  (a) "RPC 없음" = 현재 프로덕션 상태 또는 (b) "RPC 있음·정책 무변경" 뿐 → 어느 쪽도 보안 악화 없음.
  task-2996 E4 지적(취약 정책 부활)은 짝 ROLLBACK 이 `CREATE POLICY` 로 정책을 되살리는
  **DROP POLICY 포함 마이그레이션**에만 성립하며, 이 쌍은 그 전제에 해당하지 않음.

---

## 4. 1단계 적용 (프로덕션) — psycopg2, autocommit=False → COMMIT

```
$ python3 /tmp/task2999_step4_apply.py
applying /home/jay/projects/InsuRo-worktrees/task-2999-dev3/supabase/migrations/20260822T010001_task2999_push_rpc_step1.sql (7068 bytes, sha256=a081155ed4505ce5903c7c17743a47053986e555ae04892d18ff6258a82dce5d)
contains 'DROP POLICY' (non-comment): False
APPLY: COMMITTED OK

### 4-1. pg_proc chat_%push%
  proname=chat_register_push_subscription pronargs=4 args=(p_token text, p_endpoint text, p_p256dh text, p_auth text) security_definer=True owner=postgres
  proname=chat_unsubscribe_push pronargs=2 args=(p_token text, p_endpoint text) security_definer=True owner=postgres
  COUNT = 2 (expected 2)

### 4-2. push_subscriptions policies AFTER apply
  TOTAL policies = 6
  anon policies  = 3 (expected 3 — step1 does NOT change policies)
    - Anon can insert push subs | INSERT | {anon} | qual=None | with_check="(subscriber_type = 'customer'::text)"
    - Anon can read push subs | SELECT | {anon} | qual="(subscriber_type = 'customer'::text)" | with_check=None
    - Anon can update push subs | UPDATE | {anon} | qual="(subscriber_type = 'customer'::text)" | with_check="(subscriber_type = 'customer'::text)"
  'Agents can %' policies = 3 (expected 3)

### 4-3. byte-compare policies vs backup JSON
  Agents can insert push subs      : IDENTICAL=True (cmd=True roles=True qual=True with_check=True)
  Agents can read own push subs    : IDENTICAL=True (cmd=True roles=True qual=True with_check=True)
  Agents can update own push subs  : IDENTICAL=True (cmd=True roles=True qual=True with_check=True)
  Anon can insert push subs        : IDENTICAL=True (cmd=True roles=True qual=True with_check=True)
  Anon can read push subs          : IDENTICAL=True (cmd=True roles=True qual=True with_check=True)
  Anon can update push subs        : IDENTICAL=True (cmd=True roles=True qual=True with_check=True)
  ALL push_subscriptions policies unchanged vs backup: True

### 4-4. index check
  idx_push_subscriptions_conv_endpoint_type
     CREATE INDEX idx_push_subscriptions_conv_endpoint_type ON public.push_subscriptions USING btree (conversation_id, endpoint, subscriber_type)
  push_subscriptions_pkey
     CREATE UNIQUE INDEX push_subscriptions_pkey ON public.push_subscriptions USING btree (id)
  idx_push_subscriptions_conv_endpoint_type exists: True

### 4-5. EXECUTE grants on the 2 RPCs
  chat_register_push_subscription : postgres=X/postgres | anon=X/postgres | authenticated=X/postgres | service_role=X/postgres
  chat_unsubscribe_push : postgres=X/postgres | anon=X/postgres | authenticated=X/postgres | service_role=X/postgres
  chat_register_push_subscription : anon=True authenticated=True PUBLIC=False
  chat_unsubscribe_push : anon=True authenticated=True PUBLIC=False

### 4-6. push_subscriptions row count after apply
  rows = 0
```

### ★ 명세 대비 차이 1건 (임의 판단 없이 그대로 보고)

명세/마이그레이션은 `REVOKE ALL ... FROM PUBLIC` + `GRANT EXECUTE ... TO anon` 만 수행하는데,
실측 ACL 에는 `authenticated`, `service_role` 에도 EXECUTE 가 붙어 있다.
이는 마이그레이션이 부여한 것이 아니라 **Supabase 프로젝트의 `ALTER DEFAULT PRIVILEGES` 기본값**이
새 함수 생성 시 자동 부여한 것이다(`PUBLIC` 은 REVOKE 로 제거되어 `PUBLIC=False`).
동일 문장이 task-2996 원본 파일에도 그대로 있으므로 이번 작업이 만든 차이는 아니다. ANU 판단 필요 여부만 남긴다.

---

## 5. RPC 동작 검증 (응답코드 아닌 실제 행 변화로 판정, 전부 anon 롤 + ROLLBACK)

```
$ python3 /tmp/task2999_step5_rpc.py
### 5-0. find a VALID token (is_active=true AND matching conversation exists)
  FOUND valid token: <token len=36 sha256_8=d4d39a22> -> conversation_id=62910d97-84e0-4438-8a31-284cc71f4690
  active tokens=1 ; active tokens WITH matching conversation=1

### 5-1. INVALID/forged/empty/NULL tokens -> must return false (called AS ANON)
  forged token     register=False unsubscribe=False | push_subscriptions rows=0
  empty string     register=False unsubscribe=False | push_subscriptions rows=0
  NULL             register=False unsubscribe=False | push_subscriptions rows=0
  whitespace only  register=False unsubscribe=False | push_subscriptions rows=0

### 5-1b. valid token but INVALID endpoint/keys -> false
  empty endpoint   register=False
  NULL endpoint    register=False
  empty p256dh     register=False
  NULL auth        register=False

### 5-2. VALID token -> register (BEGIN / SET LOCAL ROLE anon / RPC / ROLLBACK)
  push_subscriptions rows BEFORE = 0
  current_user during RPC = anon
  chat_register_push_subscription -> True
  push_subscriptions rows AFTER  = 1   (delta=+1)
  inserted row: ('62910d97-84e0-4438-8a31-284cc71f4690', 'https://probe.invalid/dagda-2999-valid', 'P256DH_OK', 'AUTH_OK', 'customer', True)
  bound conversation matches token-resolved conv (62910d97-84e0-4438-8a31-284cc71f4690): True
  2nd register (upsert path) -> True
  rows after 2nd = 1 (expect same as AFTER)
  keys updated in place -> [('P256DH_V2', 'AUTH_V2')]
  chat_unsubscribe_push(valid) -> True
  is_active after unsubscribe -> [(False,)] (expect False)
  rows after unsubscribe = 1 (row kept, not deleted)
  (ROLLBACK done)

### 5-3. IDOR re-test THROUGH the RPC: forged token cannot touch other's conversation
  register(forged token, victim endpoint) -> False
  unsubscribe(forged token, victim endpoint) -> False
  victim row after RPC attacks: ('VICTIM_P', 'VICTIM_A', True)  (expect VICTIM_P/VICTIM_A/True = untouched)
  (ROLLBACK done)

### 5-4. FINAL integrity
  push_subscriptions rows = 0 (baseline was 0)
  probe-artifact rows = 0
  conversations rows = 1
  customer_chat_tokens rows = 1
```

> 토큰 값은 유출 방지를 위해 `<token len=.. sha256_8=..>` 로 마스킹해 기록했다.

### 판정 요약

| 케이스 | register | unsubscribe | 실제 행 변화 |
|---|---|---|---|
| 위조 토큰 `bogus-token-xxx` | false | false | 0행 (변화 없음) |
| 빈 문자열 `''` | false | false | 0행 |
| `NULL` | false | false | 0행 |
| 공백만 `'   '` | false | false | 0행 |
| 유효 토큰 + 빈/NULL endpoint·p256dh·auth | false | — | 0행 |
| **유효 토큰 + 정상 인자** | **true** | **true** | **+1행**, conversation_id 가 토큰 해석 대화와 일치 |
| 유효 토큰 재호출(upsert) | true | — | 행 수 유지(1), p256dh/auth 만 in-place 갱신 |
| 유효 토큰 unsubscribe | — | true | 행 유지(1), `is_active` → false (삭제 아님) |
| 위조 토큰으로 피해자 구독 공격 | false | false | 피해자 행 `VICTIM_P/VICTIM_A/True` 무손상 |

- `current_user during RPC = anon` 으로 확인 → **GRANT EXECUTE TO anon 이 실제로 먹는다**.
- 프로덕션에 유효 토큰이 실재하여(활성 토큰 1건, 대응 conversation 1건) **합성 데이터 없이 실데이터로 검증**했다.
- 최종 무결성: `push_subscriptions` 0행(기준선과 동일), 프로브 잔여행 0, `conversations` 1행, `customer_chat_tokens` 1행.

---

## 6. agent 경로 무손상 확인

### 6-1. `Agents can %` 정책 3건 문자 단위 대조

```
$ python3 /tmp/task2999_step6_agent.py
Agent policies found NOW: 3  (backup had 3)

=== Agents can insert push subs ===
  cmd         BEFORE='INSERT'
              AFTER ='INSERT'
              IDENTICAL=True  sha256_16=014413c294c5ac3b
  roles       BEFORE='{authenticated}'
              AFTER ='{authenticated}'
              IDENTICAL=True  sha256_16=f20373d5373f3ced
  permissive  BEFORE='PERMISSIVE'
              AFTER ='PERMISSIVE'
              IDENTICAL=True  sha256_16=cb7ad4ea374414a1
  qual        BEFORE=None
              AFTER =None
              IDENTICAL=True
  with_check  BEFORE='(conversation_id IN ( SELECT conversations.id\n   FROM conversations\n  WHERE (conversations.agent_id = auth.uid())))'
              AFTER ='(conversation_id IN ( SELECT conversations.id\n   FROM conversations\n  WHERE (conversations.agent_id = auth.uid())))'
              IDENTICAL=True  sha256_16=67b82dc4b2c9a828

=== Agents can read own push subs ===
  cmd         BEFORE='SELECT'
              AFTER ='SELECT'
              IDENTICAL=True  sha256_16=6e4261695ad1aa36
  roles       BEFORE='{authenticated}'
              AFTER ='{authenticated}'
              IDENTICAL=True  sha256_16=f20373d5373f3ced
  permissive  BEFORE='PERMISSIVE'
              AFTER ='PERMISSIVE'
              IDENTICAL=True  sha256_16=cb7ad4ea374414a1
  qual        BEFORE='(conversation_id IN ( SELECT conversations.id\n   FROM conversations\n  WHERE (conversations.agent_id = auth.uid())))'
              AFTER ='(conversation_id IN ( SELECT conversations.id\n   FROM conversations\n  WHERE (conversations.agent_id = auth.uid())))'
              IDENTICAL=True  sha256_16=67b82dc4b2c9a828
  with_check  BEFORE=None
              AFTER =None
              IDENTICAL=True

=== Agents can update own push subs ===
  cmd         BEFORE='UPDATE'
              AFTER ='UPDATE'
              IDENTICAL=True  sha256_16=6cb78ab1c6bdad28
  roles       BEFORE='{authenticated}'
              AFTER ='{authenticated}'
              IDENTICAL=True  sha256_16=f20373d5373f3ced
  permissive  BEFORE='PERMISSIVE'
              AFTER ='PERMISSIVE'
              IDENTICAL=True  sha256_16=cb7ad4ea374414a1
  qual        BEFORE='(conversation_id IN ( SELECT conversations.id\n   FROM conversations\n  WHERE (conversations.agent_id = auth.uid())))'
              AFTER ='(conversation_id IN ( SELECT conversations.id\n   FROM conversations\n  WHERE (conversations.agent_id = auth.uid())))'
              IDENTICAL=True  sha256_16=67b82dc4b2c9a828
  with_check  BEFORE=None
              AFTER =None
              IDENTICAL=True

ALL 3 agent policies byte-identical to backup: True
```

### 6-2. 전역 pg_policies before/after diff

```
$ python3 /tmp/task2999_step6b_global.py
pg_policies BEFORE (backup) = 190 rows
pg_policies AFTER  (live)   = 190 rows
added=0 removed=0 changed=0
GLOBAL pg_policies UNCHANGED by step1: True
```

→ 1단계 적용이 DB 전체 190건 정책 중 **단 한 건도** 추가/삭제/변경하지 않았음이 입증됨.

---

## 7. (추가) ROLLBACK 파일 dry-run 검증 — 트랜잭션 내 실행 후 ROLLBACK

ROLLBACK 파일이 실제로 동작하는지, 그리고 **정책을 하나도 만들지 않는지**를
BEGIN/COMMIT 을 제거해 우리 트랜잭션 안에서 실행한 뒤 되돌려 확인했다.

```
$ python3 /tmp/task2999_step7_rbtest.py
ROLLBACK file: 2213 bytes; BEGIN/COMMIT stripped for dry-run inside our own tx
non-comment 'POLICY' statements in ROLLBACK file: 0

BEFORE dry-run rollback: chat_*push* funcs=2, idx=1, push_sub policies=6, all policies=190, task2991 chat_* funcs=4
AFTER  dry-run rollback: chat_*push* funcs=0, idx=0, push_sub policies=6, all policies=190, task2991 chat_* funcs=4
EXPECT               : chat_*push* funcs=0, idx=0, push_sub policies=6 (unchanged), all policies=190 (unchanged), task2991 chat_* funcs=4 (untouched)
ROLLBACK file syntax: VALID

(dry-run ROLLED BACK — production keeps step1 applied)
VERIFY live state: chat_*push* funcs=2, idx=1, push_sub policies=6, all policies=190, task2991 chat_* funcs=4
VERIFY push_subscriptions rows=0
```

- ROLLBACK 실행 후에도 `push_subscriptions` 정책은 6건 그대로, 전역 정책도 190건 그대로 →
  **이 ROLLBACK 은 어떤 정책도 생성하지 않는다** (E4 전제 불성립을 실측으로 재확인).
- task-2991 이 만든 `chat_gate_info`/`chat_verify_and_open`/`chat_list_messages`/`chat_send_message`
  4종은 dry-run 전후 모두 4건으로 무손상.
- dry-run ROLLBACK 후 라이브 상태는 다시 funcs=2, idx=1 → 1단계는 프로덕션에 **적용된 채 유지**.

---

## 부록 — 비밀정보 취급

- DB 비밀번호: `.env` 런타임 파싱만, 출력/기록 0회.
- `customer_chat_tokens.token` 실값: `<token len=36 sha256_8=d4d39a22>` 로 마스킹.
  본 문서에 원문이 포함되지 않았음을 프로그램적으로 확인(`raw token present in evidence: False`).
