diff --git a/app/main.py b/app/main.py
index b2b4339..4d6b6fb 100644
--- a/app/main.py
+++ b/app/main.py
@@ -53,7 +53,6 @@ async def lifespan(app: FastAPI):
from workers.dedup_reconcile import run as dedup_reconcile_run
from workers.digest_worker import run as global_digest_run
from workers.file_watcher import watch_inbox
- from workers.law_monitor import run as law_monitor_run
from workers.mailplus_archive import run as mailplus_run
from workers.news_collector import run as news_collector_run
from workers.fulltext_worker import reconcile_unresolved as fulltext_reconcile_run
@@ -120,7 +119,9 @@ async def lifespan(app: FastAPI):
# safety > law > manual 우선순위로 25건씩. 6720 레거시 → 야간당 ~150건 → 약 45일 소화.
scheduler.add_job(tier_backfill_run, "interval", minutes=30, id="tier_backfill")
# 일일 스케줄 (KST)
- scheduler.add_job(law_monitor_run, CronTrigger(hour=7, timezone=KST), id="law_monitor")
+ # law_monitor 스케줄 제거 (safety-library-1 B-1 PR①, 2026-06-13) — 매일 버전 체인 밖
+ # 레거시 스냅샷을 증식하던 유일 경로 차단. 파일은 강등 보존(1사이클 관찰 후 삭제),
+ # 대체 = statute_collector (스케줄 등록은 PR② 잡 코드와 함께 — R8-B1).
scheduler.add_job(mailplus_run, CronTrigger(hour=7, timezone=KST), id="mailplus_morning")
scheduler.add_job(mailplus_run, CronTrigger(hour=18, timezone=KST), id="mailplus_evening")
scheduler.add_job(daily_digest_run, CronTrigger(hour=20, timezone=KST), id="daily_digest")
diff --git a/app/workers/statute_adapters/__init__.py b/app/workers/statute_adapters/__init__.py
new file mode 100644
index 0000000..846444c
--- /dev/null
+++ b/app/workers/statute_adapters/__init__.py
@@ -0,0 +1,25 @@
+"""statute_collector 나라별 어댑터 패키지 (plan safety-library-1 B-1).
+
+어댑터 계약 (2함수 + 상수):
+ JURISDICTION: str — 어댑터 상수 고정. 코어가 적재 직전 assert (파싱 결과 추론 금지).
+ poll_changes(client, watch_rows) -> list[ChangeEvent] — 개정 감지만 (경량 호출).
+ fetch_version(client, act, change) -> list[VersionPayload] — PR②.
+ payload 리스트: primary + annex 각각 자기 version_key (R4-M4).
+
+ChangeEvent.kind: amend / repeal / bootstrap(합성 — PR② 부트스트랩이 amend 와
+동일 ingest 경로 재사용, R6-m2).
+"""
+
+from dataclasses import dataclass
+
+
+@dataclass
+class ChangeEvent:
+ """개정 감지 이벤트 — poll_changes 산출물."""
+ family_id: str
+ kind: str # amend / repeal / bootstrap
+ new_version_key: str # KR = MST (법령일련번호)
+ title: str
+ promulgation_date: str | None = None # YYYYMMDD
+ effective_date: str | None = None # YYYYMMDD (목록 시행일자 — 조문별 차등 시행 주의)
+ revision_type: str | None = None # 제개정구분명
diff --git a/app/workers/statute_adapters/kr.py b/app/workers/statute_adapters/kr.py
new file mode 100644
index 0000000..321017d
--- /dev/null
+++ b/app/workers/statute_adapters/kr.py
@@ -0,0 +1,121 @@
+"""KR 법령 어댑터 — 국가법령정보센터 (law.go.kr DRF) (plan safety-library-1 B-1 PR①).
+
+poll_changes = lawSearch 목록 diff: 워치리스트 행별 정식 법령명 exact 조회 →
+MST(법령일련번호) != watermark 이면 ChangeEvent. law_monitor 의 검증된 호출 형태 재사용.
+
+fixture (2026-06-13 라이브 박제, tests/fixtures/statute_kr/):
+ - lawsearch_*.xml — 목록 필드: 법령ID(불변)·법령일련번호(MST)·공포일자·시행일자·제개정구분명
+ - lawservice_*.xml.gz — 전문 1콜 XML: 조문단위 853(산안기준규칙) + 별표단위 23 전부 포함
+ = 스냅샷 의미론 확정(R7-M3 ①: annex 부분 fetch 실패 개념 없음 — 같은 응답에 없는
+ 별표 = 삭제 간주 가능). 별표번호+별표가지번호 = 구조화 필드(R7-M3 ② — suffix 문자열
+ 파싱 불요, version_key 합성은 이 필드 기반. PR② fetch_version 소관).
+ - 조문 취득 방식 판정(R2-m1): 전문 1콜 + 로컬 파싱 확정 — lawjosub 조 단위 호출이면
+ 산안기준규칙(853조)은 개정당 호출 폭증. lawjosub fixture 는 보조 박제.
+
+주의: 응답의 '법령상세링크' 필드에 OC 키가 포함됨 — fixture/로그에 raw 응답을 남길 때
+새니타이즈 필수 (repo fixture 는 __OC_REDACTED__ 처리됨).
+"""
+
+import asyncio
+import os
+import xml.etree.ElementTree as ET
+
+import httpx
+
+from core.crawl_politeness import CRAWL_UA
+from core.utils import setup_logger
+from workers.statute_adapters import ChangeEvent
+
+logger = setup_logger("statute_kr")
+
+JURISDICTION = "KR"
+SOURCE_API = "law.go.kr"
+
+LAW_SEARCH_URL = "https://www.law.go.kr/DRF/lawSearch.do"
+LAW_SERVICE_URL = "https://www.law.go.kr/DRF/lawService.do"
+
+# 같은 도메인 연속 호출 간격 (일 1회 x 26콜 — 보수적)
+_POLL_DELAY_S = 1.5
+
+
+def _oc() -> str:
+ oc = os.getenv("LAW_OC", "")
+ if not oc:
+ raise RuntimeError("LAW_OC 미설정 — statute KR 어댑터 사용 불가")
+ return oc
+
+
+def parse_search_hit(xml_text: str, official_title: str) -> dict | None:
+ """lawSearch XML 에서 정식 법령명 exact match 1건 추출 (순수 함수 — fixture 테스트 대상).
+
+ 정식명 기준 exact match — 워치리스트 title 이 정식명(가운뎃점 포함)이므로 안전.
+ (law_monitor 의 하드코딩 '유해위험작업...'(점 없음)이 영구 미매칭이던 함정의 교훈:
+ 조회 키는 반드시 레지스트리의 정식명을 쓴다.)
+ """
+ root = ET.fromstring(xml_text)
+ for law in root.findall(".//law"):
+ if (law.findtext("법령명한글") or "").strip() != official_title:
+ continue
+ mst = (law.findtext("법령일련번호") or "").strip()
+ if not mst:
+ continue
+ return {
+ "mst": mst,
+ "law_id": (law.findtext("법령ID") or "").strip(),
+ "promulgation_date": (law.findtext("공포일자") or "").strip() or None,
+ "effective_date": (law.findtext("시행일자") or "").strip() or None,
+ "revision_type": (law.findtext("제개정구분명") or "").strip() or None,
+ "status_code": (law.findtext("현행연혁코드") or "").strip() or None,
+ }
+ return None
+
+
+def detect_change(hit: dict | None, act_family_id: str, act_title: str,
+ watermark: str | None) -> ChangeEvent | None:
+ """목록 hit + 워터마크 → ChangeEvent (순수 함수 — fixture 테스트 대상).
+
+ - hit 없음 = 감지 불가 (None — 호출측이 fail-loud 로그. 폐지 단정 금지:
+ 검색 누락/표기 변경 가능성과 구분 불가하므로 repeal 은 제개정구분명 기준만)
+ - MST == watermark = 변경 없음
+ - 제개정구분명에 '폐지' = repeal, 그 외 = amend
+ """
+ if hit is None:
+ return None
+ if watermark and hit["mst"] == watermark:
+ return None
+ kind = "repeal" if (hit.get("revision_type") or "").find("폐지") >= 0 else "amend"
+ return ChangeEvent(
+ family_id=act_family_id,
+ kind=kind,
+ new_version_key=hit["mst"],
+ title=act_title,
+ promulgation_date=hit.get("promulgation_date"),
+ effective_date=hit.get("effective_date"),
+ revision_type=hit.get("revision_type"),
+ )
+
+
+async def poll_changes(client: httpx.AsyncClient, watch_rows: list) -> list[ChangeEvent]:
+ """워치리스트 행별 lawSearch diff. 행 단위 실패 격리 (한 법령 실패가 나머지를 막지 않음)."""
+ oc = _oc()
+ events: list[ChangeEvent] = []
+ for act in watch_rows:
+ try:
+ resp = await client.get(
+ LAW_SEARCH_URL,
+ params={"OC": oc, "target": "law", "type": "XML", "query": act.title},
+ headers={"User-Agent": CRAWL_UA},
+ )
+ resp.raise_for_status()
+ hit = parse_search_hit(resp.text, act.title)
+ if hit is None:
+ # fail-loud: 정식명 미매칭 = 표기 변경/검색 누락 의심 — 침묵 skip 금지
+ logger.warning(f"[statute-kr] 목록 미매칭: {act.family_id} {act.title!r}")
+ else:
+ ev = detect_change(hit, act.family_id, act.title, act.watermark)
+ if ev:
+ events.append(ev)
+ except Exception as e:
+ logger.error(f"[statute-kr] poll 실패 ({act.family_id}): {type(e).__name__}: {e!r}")
+ await asyncio.sleep(_POLL_DELAY_S)
+ return events
diff --git a/app/workers/statute_collector.py b/app/workers/statute_collector.py
new file mode 100644
index 0000000..d016d5e
--- /dev/null
+++ b/app/workers/statute_collector.py
@@ -0,0 +1,76 @@
+"""statute_collector — 법령 수집 코어 (plan safety-library-1 B-1).
+
+PR① 범위 (본 파일 현재 상태) = poll_changes 관찰 전용:
+ legal_acts 워치리스트(KR, watch=true) 순회 → 어댑터 poll_changes → 감지 이벤트 로그.
+ - 워터마크 영속 안 함 (계약: '파싱 검증 통과 후에만' — ingest 는 PR②.
+ 여기서 영속하면 PR② 가 이 변경들을 영원히 못 봄)
+ - 스케줄 미등록 (PR② 에서 잡 코드 통째 — 승격·supersede·스윕·repeal — 와 함께 등록.
+ R8-B1: 승격과 스윕의 PR 분리 = 배포 갭 동안 이중 노출 무음 윈도)
+ - jurisdiction 불변식: 어댑터 상수 == 행 jurisdiction assert (파싱 추론 금지)
+
+PR② 에서 추가될 것 (카드 = 스펙): fetch_version(payload 리스트) + ingest 4축 주입
+(material_type='law'/jurisdiction=어댑터 상수/published_date=COALESCE(시행,공포)/
+license=public_domain) + 생애주기 잡(버전 시리즈 단위 승격·supersede + 상태 기반
+레거시 스윕 + repeal — 한 트랜잭션, KST) + 26 family 부트스트랩(kind='bootstrap',
+extract_meta.backfill=true) + 법령명 매핑 단위 테스트.
+
+수동 실행: docker compose exec -T fastapi python -m workers.statute_collector
+"""
+
+import asyncio
+
+import httpx
+from sqlalchemy import select
+
+from core.database import async_session
+from core.utils import setup_logger
+from models.legal_act import LegalAct
+from workers.statute_adapters import kr
+
+logger = setup_logger("statute_collector")
+
+# jurisdiction → 어댑터 모듈 (Phase 1 = KR 단독, 해외는 B-5 게이트 뒤)
+_ADAPTERS = {"KR": kr}
+
+
+async def poll_once() -> int:
+ """워치리스트 1회 폴링 — 감지 이벤트 수 반환 (관찰 전용, 상태 변경 0)."""
+ async with async_session() as session:
+ result = await session.execute(
+ select(LegalAct).where(LegalAct.watch.is_(True))
+ .order_by(LegalAct.family_id)
+ )
+ rows = result.scalars().all()
+
+ if not rows:
+ logger.warning("[statute] 워치리스트 비어 있음 — 시드(migration 356) 미적용?")
+ return 0
+
+ total = 0
+ by_jur: dict[str, list] = {}
+ for row in rows:
+ by_jur.setdefault(row.jurisdiction, []).append(row)
+
+ async with httpx.AsyncClient(timeout=30) as client:
+ for jur, acts in by_jur.items():
+ adapter = _ADAPTERS.get(jur)
+ if adapter is None:
+ logger.warning(f"[statute] 어댑터 없는 jurisdiction skip: {jur} ({len(acts)}건)")
+ continue
+ # jurisdiction 불변식 — 어댑터 상수와 행 값 일치 (적재 전 단계에서도 동일 규율)
+ assert adapter.JURISDICTION == jur, f"어댑터/행 jurisdiction 불일치: {adapter.JURISDICTION} != {jur}"
+ events = await adapter.poll_changes(client, acts)
+ for ev in events:
+ logger.info(
+ f"[statute] 변경 감지 ({ev.kind}): {ev.family_id} {ev.title} "
+ f"MST={ev.new_version_key} 공포={ev.promulgation_date} "
+ f"시행={ev.effective_date} 구분={ev.revision_type}"
+ )
+ total += len(events)
+
+ logger.info(f"[statute] poll 완료 — 워치 {len(rows)}건 중 변경 {total}건 (관찰 전용, 영속 0)")
+ return total
+
+
+if __name__ == "__main__":
+ asyncio.run(poll_once())
diff --git a/migrations/356_seed_legal_acts_kr.sql b/migrations/356_seed_legal_acts_kr.sql
new file mode 100644
index 0000000..2fbe30d
--- /dev/null
+++ b/migrations/356_seed_legal_acts_kr.sql
@@ -0,0 +1,41 @@
+-- 356_seed_legal_acts_kr.sql
+-- 안전 자료실 B-1 PR① — legal_acts KR 시드 26행 (레거시 law_monitor 26개 superset).
+-- plan: safety-library-1 B-1. watch=true 26개 전부 (R3-B1 ① — '우선순위'는 정렬일 뿐 제외 아님).
+-- 법령ID/공포/시행 = 2026-06-13 lawSearch 라이브 실측 (tests/fixtures/statute_kr/seed_26laws.tsv).
+-- ★ '유해ㆍ위험작업...' = 정식명에 가운뎃점(U+318D) — law_monitor 하드코딩(점 없음)은 exact match
+-- 불일치로 이 법령을 영구 미매칭하던 잠복 누락이었음 (R8-m1 의 watchlist 판 실증).
+-- parent 계열: 법률 → 시행령/시행규칙/위임 부령. VALUES 순서 = 부모 선행 (FK).
+INSERT INTO legal_acts (family_id, jurisdiction, law_level, title, parent_family_id, native_id, source_api, watch, poll_cycle)
+SELECT v.family_id, v.jurisdiction, v.law_level, v.title, v.parent_family_id, v.native_id, v.source_api, v.watch, v.poll_cycle
+FROM (VALUES
+ -- 법률 (statute, 14)
+ ('kr-law:001766', 'KR', 'statute', '산업안전보건법', NULL, '001766', 'law.go.kr', TRUE, 'daily'),
+ ('kr-law:013993', 'KR', 'statute', '중대재해 처벌 등에 관한 법률', NULL, '013993', 'law.go.kr', TRUE, 'daily'),
+ ('kr-law:001807', 'KR', 'statute', '건설기술 진흥법', NULL, '001807', 'law.go.kr', TRUE, 'daily'),
+ ('kr-law:000237', 'KR', 'statute', '시설물의 안전 및 유지관리에 관한 특별법', NULL, '000237', 'law.go.kr', TRUE, 'daily'),
+ ('kr-law:009502', 'KR', 'statute', '위험물안전관리법', NULL, '009502', 'law.go.kr', TRUE, 'daily'),
+ ('kr-law:000162', 'KR', 'statute', '화학물질관리법', NULL, '000162', 'law.go.kr', TRUE, 'daily'),
+ ('kr-law:011857', 'KR', 'statute', '화학물질의 등록 및 평가 등에 관한 법률', NULL, '011857', 'law.go.kr', TRUE, 'daily'),
+ ('kr-law:009503', 'KR', 'statute', '소방시설 설치 및 관리에 관한 법률', NULL, '009503', 'law.go.kr', TRUE, 'daily'),
+ ('kr-law:001854', 'KR', 'statute', '전기사업법', NULL, '001854', 'law.go.kr', TRUE, 'daily'),
+ ('kr-law:013718', 'KR', 'statute', '전기안전관리법', NULL, '013718', 'law.go.kr', TRUE, 'daily'),
+ ('kr-law:001850', 'KR', 'statute', '고압가스 안전관리법', NULL, '001850', 'law.go.kr', TRUE, 'daily'),
+ ('kr-law:001849', 'KR', 'statute', '액화석유가스의 안전관리 및 사업법', NULL, '001849', 'law.go.kr', TRUE, 'daily'),
+ ('kr-law:001872', 'KR', 'statute', '근로기준법', NULL, '001872', 'law.go.kr', TRUE, 'daily'),
+ ('kr-law:002016', 'KR', 'statute', '환경영향평가법', NULL, '002016', 'law.go.kr', TRUE, 'daily'),
+ -- 대통령령 (decree, 7)
+ ('kr-law:003786', 'KR', 'decree', '산업안전보건법 시행령', 'kr-law:001766', '003786', 'law.go.kr', TRUE, 'daily'),
+ ('kr-law:014159', 'KR', 'decree', '중대재해 처벌 등에 관한 법률 시행령', 'kr-law:013993', '014159', 'law.go.kr', TRUE, 'daily'),
+ ('kr-law:002111', 'KR', 'decree', '건설기술 진흥법 시행령', 'kr-law:001807', '002111', 'law.go.kr', TRUE, 'daily'),
+ ('kr-law:009707', 'KR', 'decree', '위험물안전관리법 시행령', 'kr-law:009502', '009707', 'law.go.kr', TRUE, 'daily'),
+ ('kr-law:004390', 'KR', 'decree', '화학물질관리법 시행령', 'kr-law:000162', '004390', 'law.go.kr', TRUE, 'daily'),
+ ('kr-law:009694', 'KR', 'decree', '소방시설 설치 및 관리에 관한 법률 시행령', 'kr-law:009503', '009694', 'law.go.kr', TRUE, 'daily'),
+ ('kr-law:002246', 'KR', 'decree', '고압가스 안전관리법 시행령', 'kr-law:001850', '002246', 'law.go.kr', TRUE, 'daily'),
+ -- 부령 (rule, 5)
+ ('kr-law:007364', 'KR', 'rule', '산업안전보건법 시행규칙', 'kr-law:001766', '007364', 'law.go.kr', TRUE, 'daily'),
+ ('kr-law:007363', 'KR', 'rule', '산업안전보건기준에 관한 규칙', 'kr-law:001766', '007363', 'law.go.kr', TRUE, 'daily'),
+ ('kr-law:007844', 'KR', 'rule', '유해ㆍ위험작업의 취업 제한에 관한 규칙', 'kr-law:001766', '007844', 'law.go.kr', TRUE, 'daily'),
+ ('kr-law:006175', 'KR', 'rule', '건설기술 진흥법 시행규칙', 'kr-law:001807', '006175', 'law.go.kr', TRUE, 'daily'),
+ ('kr-law:009732', 'KR', 'rule', '위험물안전관리법 시행규칙', 'kr-law:009502', '009732', 'law.go.kr', TRUE, 'daily')
+) AS v(family_id, jurisdiction, law_level, title, parent_family_id, native_id, source_api, watch, poll_cycle)
+WHERE NOT EXISTS (SELECT 1 FROM legal_acts la WHERE la.family_id = v.family_id);
diff --git a/tests/fixtures/statute_kr/lawjosub_probe.xml b/tests/fixtures/statute_kr/lawjosub_probe.xml
new file mode 100644
index 0000000..811a59f
--- /dev/null
+++ b/tests/fixtures/statute_kr/lawjosub_probe.xml
@@ -0,0 +1,46 @@
+
+<법령 법령키="0017662026021921374">
+<기본정보>
+<법령ID>001766법령ID>
+<공포일자>20260219공포일자>
+<공포번호>21374공포번호>
+<언어>한글언어>
+<법종구분 법종구분코드="A0002">법률법종구분>
+<법령명_한글>법령명_한글>
+<법령명_한자>법령명_한자>
+<제명변경여부>N제명변경여부>
+<한글법령여부>Y한글법령여부>
+<편장절관>40040000편장절관>
+<소관부처 소관부처코드="1492000">고용노동부소관부처>
+<전화번호>044-202-8810, 8813, 8815, 8997전화번호>
+<시행일자>20260601시행일자>
+<제개정구분>일부개정제개정구분>
+<조문별시행일자>20260601조문별시행일자>
+<조문시행일자문자열>20260801:제10조의2, 제23조, 제175조제4항제1호의2조문시행일자문자열>
+<별표편집여부>N별표편집여부>
+<공포법령여부>Y공포법령여부>
+<시행일기준편집여부>Y시행일기준편집여부>
+기본정보>
+<조문>
+<조문단위 조문키="0001000">
+<조문번호>1조문번호>
+<조문여부>전문조문여부>
+<조문시행일자>20260601조문시행일자>
+<조문이동이전>조문이동이전>
+<조문이동이후>조문이동이후>
+<조문변경여부>N조문변경여부>
+조문단위>
+<조문단위 조문키="0001001">
+<조문번호>1조문번호>
+<조문여부>조문조문여부>
+<조문제목>조문제목>
+<조문시행일자>20260601조문시행일자>
+<조문이동이전>조문이동이전>
+<조문이동이후>조문이동이후>
+<조문변경여부>N조문변경여부>
+<조문내용>
+]]>
+조문내용>
+조문단위>
+조문>
+법령>
diff --git a/tests/fixtures/statute_kr/lawsearch_rule.xml b/tests/fixtures/statute_kr/lawsearch_rule.xml
new file mode 100644
index 0000000..a88c62c
--- /dev/null
+++ b/tests/fixtures/statute_kr/lawsearch_rule.xml
@@ -0,0 +1,2 @@
+law<키워드>산업안전보건기준에 관한 규칙키워드>11100success<법령일련번호>273603법령일련번호><현행연혁코드>현행현행연혁코드><법령명한글>법령명한글><법령약칭명>법령약칭명><법령ID>007363법령ID><공포일자>20250901공포일자><공포번호>00450공포번호><제개정구분명>일부개정제개정구분명><소관부처코드>1492000소관부처코드><소관부처명>고용노동부소관부처명><법령구분명>고용노동부령법령구분명><공동부령정보>공동부령정보><시행일자>20260302시행일자><자법타법여부>자법타법여부><법령상세링크>/DRF/lawService.do?OC=__OC_REDACTED__&target=law&MST=273603&type=HTML&mobileYn=&efYd=20260302법령상세링크>
+
diff --git a/tests/fixtures/statute_kr/lawsearch_sanab.xml b/tests/fixtures/statute_kr/lawsearch_sanab.xml
new file mode 100644
index 0000000..cdc76b7
--- /dev/null
+++ b/tests/fixtures/statute_kr/lawsearch_sanab.xml
@@ -0,0 +1,2 @@
+law<키워드>산업안전보건법키워드>31300success<법령일련번호>283449법령일련번호><현행연혁코드>현행현행연혁코드><법령명한글>법령명한글><법령약칭명>법령약칭명><법령ID>001766법령ID><공포일자>20260219공포일자><공포번호>21374공포번호><제개정구분명>일부개정제개정구분명><소관부처코드>1492000소관부처코드><소관부처명>고용노동부소관부처명><법령구분명>법률법령구분명><공동부령정보>공동부령정보><시행일자>20260601시행일자><자법타법여부>자법타법여부><법령상세링크>/DRF/lawService.do?OC=__OC_REDACTED__&target=law&MST=283449&type=HTML&mobileYn=&efYd=20260601법령상세링크><법령일련번호>284771법령일련번호><현행연혁코드>현행현행연혁코드><법령명한글>법령명한글><법령약칭명>법령약칭명><법령ID>003786법령ID><공포일자>20260324공포일자><공포번호>36220공포번호><제개정구분명>타법개정제개정구분명><소관부처코드>1492000소관부처코드><소관부처명>고용노동부소관부처명><법령구분명>대통령령법령구분명><공동부령정보>공동부령정보><시행일자>20260324시행일자><자법타법여부>자법타법여부><법령상세링크>/DRF/lawService.do?OC=__OC_REDACTED__&target=law&MST=284771&type=HTML&mobileYn=&efYd=20260324법령상세링크><법령일련번호>286657법령일련번호><현행연혁코드>현행현행연혁코드><법령명한글>법령명한글><법령약칭명>법령약칭명><법령ID>007364법령ID><공포일자>20260529공포일자><공포번호>00470공포번호><제개정구분명>일부개정제개정구분명><소관부처코드>1492000소관부처코드><소관부처명>고용노동부소관부처명><법령구분명>고용노동부령법령구분명><공동부령정보>공동부령정보><시행일자>20260601시행일자><자법타법여부>자법타법여부><법령상세링크>/DRF/lawService.do?OC=__OC_REDACTED__&target=law&MST=286657&type=HTML&mobileYn=&efYd=20260601법령상세링크>
+
diff --git a/tests/fixtures/statute_kr/lawservice_rule.xml.gz b/tests/fixtures/statute_kr/lawservice_rule.xml.gz
new file mode 100644
index 0000000..92595bc
Binary files /dev/null and b/tests/fixtures/statute_kr/lawservice_rule.xml.gz differ
diff --git a/tests/fixtures/statute_kr/lawservice_sanab.xml.gz b/tests/fixtures/statute_kr/lawservice_sanab.xml.gz
new file mode 100644
index 0000000..154916c
Binary files /dev/null and b/tests/fixtures/statute_kr/lawservice_sanab.xml.gz differ
diff --git a/tests/fixtures/statute_kr/seed_26laws.tsv b/tests/fixtures/statute_kr/seed_26laws.tsv
new file mode 100644
index 0000000..8dbfe3f
--- /dev/null
+++ b/tests/fixtures/statute_kr/seed_26laws.tsv
@@ -0,0 +1,26 @@
+산업안전보건법 001766 283449 20260219 20260601 법률
+산업안전보건법 시행령 003786 284771 20260324 20260324 대통령령
+산업안전보건법 시행규칙 007364 286657 20260529 20260601 고용노동부령
+산업안전보건기준에 관한 규칙 007363 273603 20250901 20260302 고용노동부령
+유해위험작업의 취업 제한에 관한 규칙 MISS
+중대재해 처벌 등에 관한 법률 013993 228817 20210126 20220127 법률
+중대재해 처벌 등에 관한 법률 시행령 014159 277417 20251001 20251001 대통령령
+건설기술 진흥법 001807 276921 20251001 20251001 법률
+건설기술 진흥법 시행령 002111 286847 20260609 20260609 대통령령
+건설기술 진흥법 시행규칙 006175 286885 20260611 20260611 국토교통부령
+시설물의 안전 및 유지관리에 관한 특별법 000237 266683 20241203 20251204 법률
+위험물안전관리법 009502 259933 20240206 20250807 법률
+위험물안전관리법 시행령 009707 273077 20250805 20250807 대통령령
+위험물안전관리법 시행규칙 009732 262765 20240520 20250521 행정안전부령
+화학물질관리법 000162 276815 20251001 20251001 법률
+화학물질관리법 시행령 004390 280507 20251223 20251223 대통령령
+화학물질의 등록 및 평가 등에 관한 법률 011857 279805 20251111 20260512 법률
+소방시설 설치 및 관리에 관한 법률 009503 236977 20211130 20241201 법률
+소방시설 설치 및 관리에 관한 법률 시행령 009694 284781 20260324 20260324 대통령령
+전기사업법 001854 283981 20260310 20260310 법률
+전기안전관리법 013718 268805 20250131 20260201 법률
+고압가스 안전관리법 001850 283919 20260310 20260310 법률
+고압가스 안전관리법 시행령 002246 286839 20260609 20260609 대통령령
+액화석유가스의 안전관리 및 사업법 001849 276549 20251001 20251128 법률
+근로기준법 001872 265959 20241022 20251023 법률
+환경영향평가법 002016 276833 20251001 20251023 법률
\ No newline at end of file
diff --git a/tests/test_statute_kr_adapter.py b/tests/test_statute_kr_adapter.py
new file mode 100644
index 0000000..715c9e4
--- /dev/null
+++ b/tests/test_statute_kr_adapter.py
@@ -0,0 +1,89 @@
+"""B-1 PR① — KR 어댑터 순수 파서 fixture 테스트 (plan safety-library-1).
+
+fixture = 2026-06-13 law.go.kr 라이브 박제 (OC 새니타이즈, tests/fixtures/statute_kr/).
+파서는 순수 함수라 httpx/DB 불요 — 컨테이너 밖 로컬 실행.
+"""
+
+import gzip
+import sys
+import xml.etree.ElementTree as ET
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).parent.parent / "app"))
+
+from workers.statute_adapters import ChangeEvent # noqa: E402
+from workers.statute_adapters.kr import detect_change, parse_search_hit # noqa: E402
+
+FIX = Path(__file__).parent / "fixtures" / "statute_kr"
+
+
+def _read(name: str) -> str:
+ p = FIX / name
+ if name.endswith(".gz"):
+ return gzip.decompress(p.read_bytes()).decode("utf-8")
+ return p.read_text(encoding="utf-8")
+
+
+def test_parse_search_hit_exact_match():
+ hit = parse_search_hit(_read("lawsearch_sanab.xml"), "산업안전보건법")
+ assert hit is not None
+ assert hit["law_id"] == "001766"
+ assert hit["mst"] == "283449"
+ assert hit["promulgation_date"] == "20260219"
+ assert hit["effective_date"] == "20260601"
+ assert hit["status_code"] == "현행"
+
+
+def test_parse_search_hit_rejects_partial_name():
+ # totalCnt 3 인 응답에서 '산업안전보건법 시행령' 등 부분 일치는 비매칭이어야 함
+ hit = parse_search_hit(_read("lawsearch_sanab.xml"), "산업안전보건")
+ assert hit is None
+
+
+def test_detect_change_same_watermark_is_silent():
+ hit = parse_search_hit(_read("lawsearch_sanab.xml"), "산업안전보건법")
+ assert detect_change(hit, "kr-law:001766", "산업안전보건법", watermark="283449") is None
+
+
+def test_detect_change_new_mst_is_amend():
+ hit = parse_search_hit(_read("lawsearch_sanab.xml"), "산업안전보건법")
+ ev = detect_change(hit, "kr-law:001766", "산업안전보건법", watermark="283448")
+ assert isinstance(ev, ChangeEvent)
+ assert ev.kind == "amend"
+ assert ev.new_version_key == "283449"
+ assert ev.effective_date == "20260601"
+
+
+def test_detect_change_empty_watermark_is_amend():
+ # 첫 폴링(워터마크 부재) = 변경으로 감지 — PR② 부트스트랩 전 관찰 모드의 기대 동작
+ hit = parse_search_hit(_read("lawsearch_sanab.xml"), "산업안전보건법")
+ ev = detect_change(hit, "kr-law:001766", "산업안전보건법", watermark=None)
+ assert ev is not None and ev.kind == "amend"
+
+
+def test_detect_change_repeal_keyword():
+ hit = {"mst": "9", "revision_type": "폐지", "promulgation_date": None,
+ "effective_date": None, "law_id": "x", "status_code": None}
+ ev = detect_change(hit, "kr-law:x", "x", watermark="1")
+ assert ev is not None and ev.kind == "repeal"
+
+
+def test_lawservice_snapshot_semantics_rule():
+ """R7-M3 판정 박제: 전문 1콜 XML = 조문+별표 전체 스냅샷 (PR② payload 계약의 전제)."""
+ root = ET.fromstring(_read("lawservice_rule.xml.gz"))
+ articles = root.findall(".//조문단위")
+ annexes = root.findall(".//별표단위")
+ assert len(articles) >= 800, "산안기준규칙 조문 853 기대 — 전문 1콜 판정 근거"
+ assert len(annexes) == 23, "별표 23 전부 본문 XML 포함 = 스냅샷 의미론"
+ # R7-M3 ②: 별표 식별 = 구조화 필드 (suffix 문자열 파싱 불요)
+ first = annexes[0]
+ assert first.findtext("별표번호") is not None
+ assert first.findtext("별표가지번호") is not None
+
+
+def test_lawservice_sanab_basic_info():
+ root = ET.fromstring(_read("lawservice_sanab.xml.gz"))
+ assert root.findtext(".//법령ID") == "001766"
+ assert len(root.findall(".//조문단위")) >= 200
+ # 별표 없는 법령 = 별표단위 0 (스냅샷 의미론의 반대쪽 케이스)
+ assert len(root.findall(".//별표단위")) == 0