diff --git a/.gitattributes b/.gitattributes
index d6b33b5..eca344a 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -19,3 +19,10 @@
*.webp binary
*.ico binary
*.pdf binary
+
+# 생성 산출물 — 소스는 같은 폴더의 spec JSON 이다.
+#
+# 15,000줄짜리 생성 HTML 이라 텍스트 diff 는 리뷰에 쓸모가 없고(최장 라인 41,844자),
+# 두 브랜치가 각각 재생성하면 손으로 해소할 수 없는 충돌이 난다. 정답은 항상
+# "재생성"이므로 머지를 텍스트로 시도하지 않게 해 도구가 그 사실을 강제하게 한다.
+docs/diagrams/*.html linguist-generated=true -diff merge=binary
diff --git a/.gitignore b/.gitignore
index 4b20c85..ccdf5ef 100644
--- a/.gitignore
+++ b/.gitignore
@@ -45,3 +45,10 @@ Thumbs.db
# PowerShell module-analysis cache written into cwd by some automation
Microsoft/
+
+# 다이어그램 검증 사이드카 (scripts/rebuild-diagrams.ps1 이 재생성한다)
+#
+# visual-check 산출물은 브라우저 증거이지 납품물이 아니다. 게다가 *.visual-check.json
+# 은 개발 머신의 절대 경로와 크롬 설치 경로를 평문으로 담는다 - 폐쇄망 납품 저장소가
+# removable media 로 건너가는 구조라 이력에 한 번 들어가면 제거가 어렵다.
+docs/diagrams/*.visual-check.*
diff --git a/docs/INDEX.md b/docs/INDEX.md
index 0193cf2..a61c121 100644
--- a/docs/INDEX.md
+++ b/docs/INDEX.md
@@ -1,6 +1,8 @@
# Project INDEX
-- 2026-09-19 · 다이어그램 4종으로 확장 — `system-architecture`(폐쇄망 경계·SSRF 가드 앞 호출 게이트), `operator-review-workflow`(4레인, 비상 차단 시 심사 계속 경로), `submission-lifecycle`(분석 실패·보류가 분석으로 복귀하는 두 고리)를 신설하고 기존 `evidence-dataflow` 포함 4종의 범례를 한글화했다. 렌더러 기본 범례가 "User UI / Agent logic / policy·PII" 같은 타 도메인 문구였다. 작성 중 내린 고도 판단: 아키텍처에서는 analyzer/scorer 를 한 컴포넌트로 합쳤고(세부는 dataflow 담당), 라이프사이클에서 "재분석 요청"은 decisionStatus 가 아닌 운영자 액션이라 상태에서 제외해 복귀 엣지를 하나로 줄였다. 4종 모두 archify showcase 통과(아티팩트 검사 9/9 · 합성 오류 0 · 경고 0) + visual-check 통과(1440×900 · 2048×1320 각 Light/Dark, 오버플로 0). 수신증 — dataflow html a62f0f5c / architecture f5a4b6e1 / workflow 402914a3 / lifecycle 62e934a3
+- 2026-09-21 · 다이어그램 감사 후 **드리프트 게이트 신설**. 에이전트 6축 감사가 주간 한도로 절반(2축)만 완주했고 검증 단계도 죽어, 완주분 15건을 저널에서 복구한 뒤 결정적 주장은 직접 재검증했다. 확정된 구멍 — 그림 4종의 재생성 절차가 **문서대로는 실행 불가**(`archify` 실행파일이 PATH 에 없음), 소스↔산출물 동기화를 보는 장치 0개, 사이드카 24개가 개발 머신 절대경로를 담은 채 미추적·비ignore, 임베드 폰트에 **한글 구간 0건**(`U+AC00` 매치 0 · 폴백은 중국어 폰트뿐). 실수정: `scripts/rebuild-diagrams.ps1`(validate→deliver→visual-check→manifest, 실행 확인)과 `scripts/update_diagram_manifest.py`, 수신증 `docs/diagrams/manifest.json`, 게이트 `tests/test_diagram_sync.py` 14건(HTML 직접 수정·spec 만 수정 두 시나리오 모두 실제로 차단됨을 실증), `.gitignore` 에 사이드카 제외(미추적 24→0), `.gitattributes` 에 `linguist-generated -diff merge=binary`. 재생성해도 4종 sha256 불변 — 바이트 결정성 재확인. 한글 폰트·영어 뷰어 UI·architecture 좌표 하드코딩·lifecycle 손튜닝 8개소는 PROJECT_MAP 부채 표에 등재. **ruff clean · pytest 600 passed**(게이트 +14)
+
+- 2026-09-19 · 다이어그램 4종으로 확장 — `system-architecture`(폐쇄망 경계·SSRF 가드 앞 호출 게이트), `operator-review-workflow`(4레인, 비상 차단 시 심사 계속 경로), `submission-lifecycle`(분석 실패·보류가 분석으로 복귀하는 두 고리)를 신설하고 기존 `evidence-dataflow` 포함 4종의 **범례 항목**을 한글화했다(렌더러 기본값이 "User UI / Agent logic / policy·PII" 같은 타 도메인 문구였다). **뷰어 크롬은 영어로 남는다** — `locale` enum 이 en/zh-CN 뿐이고 템플릿이 `` 을 하드코딩하므로 Light/Dark·Present·Export·범례 제목("Legend")은 저자가 바꿀 수 없다. 9/21 감사에서 확인. 작성 중 내린 고도 판단: 아키텍처에서는 analyzer/scorer 를 한 컴포넌트로 합쳤고(세부는 dataflow 담당), 라이프사이클에서 "재분석 요청"은 decisionStatus 가 아닌 운영자 액션이라 상태에서 제외해 복귀 엣지를 하나로 줄였다. 4종 모두 archify showcase 통과(아티팩트 검사 9/9 · 합성 오류 0 · 경고 0) + visual-check 통과(1440×900 · 2048×1320 각 Light/Dark, 오버플로 0). 수신증 — dataflow html a62f0f5c / architecture f5a4b6e1 / workflow 402914a3 / lifecycle 62e934a3
- 2026-09-19 · `docs/diagrams/evidence-dataflow.html` 신설 — 증거 데이터 흐름(수집 → 경계 검사 → 정규화·채점 → 영속화 → 소비) 자체 완결 다이어그램. 9/18 리뷰 수정의 불변식을 그림으로 고정한다: **제공자별 풀·버킷**(RiskScorer v3), **라운드트립이 원 점수를 재현**(`_evidence_payload` ↔ `_domain_evidence_from_ui`), **host 는 저장 시 해석·복원 시 우선 반환**, 지식DB 지문 매칭이 풀링 밖 유일한 high 승격 경로. 프로덕션 LLM 경로가 영속화 payload 를 복원해 쓴다는 사실(`store_operations.py:487`, `EvidenceEnricher` 는 테스트 전용)도 반영. archify showcase 검증 통과(아티팩트 검사 9/9 · 합성 오류 0 · 경고 0), 브라우저 증거 통과(1440×900 · 2048×1320 각 Light/Dark 4컷, 오버플로 0, 최소 투영 텍스트 6.71px ≥ 6px). spec sha256 42c334cd · html sha256 268f3352 (822,655 B). CDN·원격 fetch 없음 — 폐쇄망 규칙 준수
diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md
index 05bbd78..7b13a21 100644
--- a/docs/PROJECT_MAP.md
+++ b/docs/PROJECT_MAP.md
@@ -169,9 +169,28 @@ WAL 적용됨. DB 기본 경로는 `data/copyrighter.sqlite3`(gitignore).
## 다이어그램
-소스 JSON 과 산출물 HTML 이 같은 폴더에 쌍으로 있다. **그림을 고치려면 JSON 을 고쳐
-`archify validate` → `deliver` → `visual-check` 로 재생성한다** (HTML 직접 수정 금지).
-모두 자체 완결 HTML 이라 외부 요청이 없다.
+소스 JSON 과 산출물 HTML 이 같은 폴더에 쌍으로 있다. **HTML 직접 수정 금지** —
+고칠 것은 언제나 spec JSON 이고, 재생성은 스크립트 하나다:
+
+```powershell
+.\scripts
ebuild-diagrams.ps1 # validate -> deliver -> visual-check -> manifest
+.\scripts
ebuild-diagrams.ps1 -SkipVisualCheck # 브라우저 없는 호스트
+```
+
+`tests/test_diagram_sync.py` 가 `manifest.json` 의 sha256 으로 이 계약을 강제한다
+(브라우저·Node·archify 없이 도는 순수 파일 비교). HTML 을 직접 고치거나 spec 만
+고치고 재생성을 잊으면 `verify.ps1` 에서 막힌다. 두 시나리오 모두 실증 확인됨.
+
+**문서 재생성 전용 요구사항** (런타임 아님, 폐쇄망 타깃에는 불필요):
+Node 18+ 와 archify 스킬(`$env:ARCHIFY_HOME`, 기본값 `~/.claude/skills/archify`),
+visual-check 단계에만 Chrome/Chromium. 산출 당시 generator 는 `archify 2.17.0-dev.1`
+이고 `manifest.json` 에 기록된다 — 스킬 버전이 바뀌면 같은 spec 에서도 바이트가
+달라지므로, 해시가 깨졌을 때 코드 변경과 툴 변경을 구분하는 근거가 된다.
+
+모두 자체 완결 HTML 이라 외부 요청이 없다(9/21 감사에서 실측: src/href/@import/fetch
+모두 0건). 단 **file:// 로 여는 산출물**이다 — 운영자 콘솔의 static_root 로 서빙하면
+app-shell CSP(`script-src 'self'`, `http_app.py:514`)가 인라인 스크립트 4개를 차단해
+상호작용이 죽는다. 서빙이 필요하면 CSP 완화가 아니라 별도 라우트에서 nonce 정책을 쓴다.
| 파일 | 내용 | 타입 |
|---|---|---|
@@ -189,4 +208,8 @@ WAL 적용됨. DB 기본 경로는 `data/copyrighter.sqlite3`(gitignore).
| 밴드 임계·풀 상한 미캘리브레이션 | `analysis/risk_scoring.py` | 판정 이력 0건이라 70/30 임계가 실제 판정과 대조된 적 없다. v3 의 두 풀(`WEB_PRESENCE_MAX_POINTS` 60 은 **제공자별** / `WEB_ENTITY_MAX_POINTS` 20)은 신호의 구조적 성질로 정한 값이고 상관 검증 전이다 — 판정 30~50건 후 `decision_stats` 교차표가 첫 근거가 된다 |
| 출처 신호 2종 0점 | `analysis/provenance.py` | 캘리브레이션 전. 실측 코퍼스에서 가치 미입증. 방송캡처는 `MIN_CAPTURE_CATEGORIES=2` 에서 geometry 가 1개 카테고리라 자막 없는 캡처는 구조적으로 도달 불가 |
| 테스트가 로컬 LLM 에 실접속 | `integrations/env_clients.py:260` | `_llm_assistant` 가 항상 `OLLAMA_BASE_URL` 기본값(`127.0.0.1:11434`)으로 클라이언트를 만들므로, provider_runtime 없이 만든 store 를 쓰는 테스트는 **ollama 가 떠 있으면 실호출**을 한다. `test_http_app.py` 3건이 그 상태에서 소켓 타임아웃으로 실패한다(`OLLAMA_BASE_URL=http://127.0.0.1:1` 로 돌리면 통과). 폐쇄망 프로젝트의 테스트가 외부 서비스의 **부재**에 의존하는 상태 |
+| 다이어그램에 한글 폰트 미번들 | `docs/diagrams/*.html` | 임베드된 JetBrains Mono 의 unicode-range 6개에 **한글 구간이 없다**(`U+AC00` 매치 0). 폴백 스택은 중국어 폰트뿐(`Noto Sans Mono CJK SC`/`PingFang SC`/`Microsoft YaHei`). 렌더 텍스트의 40~72% 가 한글이라 타깃 폰트에 의존한다 — 한국어 폰트 없는 최소 설치 호스트에서는 두부(tofu). archify 에 저자용 폰트 옵션이 없어 spec 으로는 못 고친다(레이아웃 폭은 전각으로 측정되므로 깨짐이 아니라 글자 문제). 고객 인수물로 확정되면 blocker |
+| 뷰어 UI 가 영어 고정 | `docs/diagrams/*.html` | `locale` enum 이 `en`/`zh-CN` 뿐이고 템플릿이 `` 하드코딩. 저자 내용은 한국어인데 조작 UI·aria 라벨·범례 제목은 영어. 스크린리더가 한글 본문을 영어로 읽는다 |
+| architecture spec 만 좌표 하드코딩 | `docs/diagrams/system-architecture.architecture.json` | 컴포넌트 11개 전부 `pos`+`size` 수기, `size` 는 [124,62] 고정. 라벨 21자에서 validate 하드 실패(현재 `store_remote_fetch` 18자 → 여유 2글자). workflow spec 은 기하 하드코딩 0개이므로 같은 저장소에 더 나은 패턴이 있다 |
+| lifecycle 기하 손튜닝 8개소 | `docs/diagrams/submission-lifecycle.lifecycle.json` | `labelDy`×5·`channelX`×2·`yOffset`×1 이 현재 배치 전제의 절대 픽셀이다. 상태를 하나 추가하면 **손대지 않은 기존 전이까지** 검증 실패(실증: 오류 5건 중 4건이 기존 `t-reject`). 상태 추가 시 전량 재검증 필요 |
| 7/11 리뷰 연기 항목 | [reviews/2026-07-11-remediation-checklist.md](reviews/2026-07-11-remediation-checklist.md) | 각 항목에 연기 근거 기재 |
diff --git a/docs/diagrams/manifest.json b/docs/diagrams/manifest.json
new file mode 100644
index 0000000..9cbcce0
--- /dev/null
+++ b/docs/diagrams/manifest.json
@@ -0,0 +1,38 @@
+{
+ "note": "archify deliver 는 바이트 결정적이다. 이 수신증은 tests/test_diagram_sync.py 가 강제한다 - HTML 을 직접 고치거나 spec 만 고치고 재생성을 잊으면 커밋 게이트가 막는다. 갱신은 손으로 하지 말고 scripts/rebuild-diagrams.ps1 로 한다.",
+ "generator": "archify 2.17.0-dev.1",
+ "diagrams": [
+ {
+ "type": "architecture",
+ "spec": "system-architecture.architecture.json",
+ "html": "system-architecture.html",
+ "specSha256": "8359880ec5a7e64012cf7533169d279fff364b36c84855a1678ea8be2414d699",
+ "htmlSha256": "f5a4b6e1c6f94ab65bd55d6b348c9d1257db194508285541e3fb94acdfabd44c",
+ "htmlBytes": 816201
+ },
+ {
+ "type": "dataflow",
+ "spec": "evidence-dataflow.dataflow.json",
+ "html": "evidence-dataflow.html",
+ "specSha256": "7503e9be9f100440d965877f6bb7e9ce8863f85015a5144b7a30404a524ba65a",
+ "htmlSha256": "a62f0f5cab31f880a98877f0d1b4353d09c86fb1152157fbdebe62bcd491114e",
+ "htmlBytes": 822657
+ },
+ {
+ "type": "workflow",
+ "spec": "operator-review-workflow.workflow.json",
+ "html": "operator-review-workflow.html",
+ "specSha256": "222d2d7565ee06c783a93868775732d96a9300e3baa4e8ddff1f8a57a421e79c",
+ "htmlSha256": "402914a3d29ad899d136b7e011bbe2aaf7d36a2a2880ee59fa33929bb614974d",
+ "htmlBytes": 818137
+ },
+ {
+ "type": "lifecycle",
+ "spec": "submission-lifecycle.lifecycle.json",
+ "html": "submission-lifecycle.html",
+ "specSha256": "b53d44dc70393c7a0a7746d774563c61e412eceafed5d10fd1a5e0e509aa3ac9",
+ "htmlSha256": "62e934a32e3b3c5b1660794d8919e2fc1910f3937e82e882f464abebbfbb2634",
+ "htmlBytes": 812325
+ }
+ ]
+}
diff --git a/scripts/rebuild-diagrams.ps1 b/scripts/rebuild-diagrams.ps1
new file mode 100644
index 0000000..a15f8de
--- /dev/null
+++ b/scripts/rebuild-diagrams.ps1
@@ -0,0 +1,98 @@
+<#
+.SYNOPSIS
+ docs/diagrams 의 다이어그램 4종을 spec JSON 에서 다시 생성한다.
+
+.DESCRIPTION
+ PROJECT_MAP 에 적혀 있던 "archify validate -> deliver -> visual-check" 절차는
+ 그대로는 실행되지 않았다 - archify 라는 실행 파일이 PATH 에 없고, 실제로는
+ 저장소 밖 사용자 전역 스킬을 node 로 불러야 한다. 이 스크립트가 그 절차다.
+
+ 순서:
+ 1. validate (--quality showcase) 합성/기하 검증
+ 2. deliver 산출물 HTML 을 원자적으로 교체
+ 3. visual-check 실제 브라우저 증거 (Chrome 필요)
+ 4. manifest 갱신 tests/test_diagram_sync.py 의 수신증
+
+ 산출물은 바이트 결정적이다. 그래서 4번의 수신증만으로 드리프트를 잡을 수 있고,
+ 테스트는 브라우저도 Node 도 없이 돈다.
+
+.PARAMETER SkipVisualCheck
+ 3단계를 건너뛴다. Chrome 이 없는 빌드 호스트용. 건너뛰면 그 사실을 출력한다
+ (조용한 통과 금지).
+
+.PARAMETER ArchifyHome
+ archify 스킬 경로. 기본값은 $env:ARCHIFY_HOME, 없으면 ~/.claude/skills/archify.
+
+.EXAMPLE
+ .\scripts
+ebuild-diagrams.ps1
+.EXAMPLE
+ .\scripts
+ebuild-diagrams.ps1 -SkipVisualCheck
+#>
+[CmdletBinding()]
+param(
+ [switch]$SkipVisualCheck,
+ [string]$ArchifyHome
+)
+
+$ErrorActionPreference = 'Stop'
+$repoRoot = Split-Path -Parent $PSScriptRoot
+Set-Location -LiteralPath $repoRoot
+
+if (-not $ArchifyHome) {
+ if ($env:ARCHIFY_HOME) { $ArchifyHome = $env:ARCHIFY_HOME }
+ else { $ArchifyHome = Join-Path $HOME '.claude/skills/archify' }
+}
+$cli = Join-Path $ArchifyHome 'bin/archify.mjs'
+if (-not (Test-Path -LiteralPath $cli)) {
+ Write-Host "archify CLI not found: $cli" -ForegroundColor Red
+ Write-Host 'Set -ArchifyHome or $env:ARCHIFY_HOME to the archify skill directory.' -ForegroundColor Red
+ exit 1
+}
+
+# (type, basename) - tests/test_diagram_sync.py 및 update_diagram_manifest.py 와 같은 목록
+$diagrams = @(
+ @{ Type = 'architecture'; Base = 'system-architecture' },
+ @{ Type = 'dataflow'; Base = 'evidence-dataflow' },
+ @{ Type = 'workflow'; Base = 'operator-review-workflow' },
+ @{ Type = 'lifecycle'; Base = 'submission-lifecycle' }
+)
+
+$failures = @()
+$skipped = @()
+
+foreach ($d in $diagrams) {
+ $spec = Join-Path $repoRoot ("docs/diagrams/{0}.{1}.json" -f $d.Base, $d.Type)
+ $html = Join-Path $repoRoot ("docs/diagrams/{0}.html" -f $d.Base)
+
+ Write-Host "`n=== $($d.Base) ($($d.Type)) ===" -ForegroundColor Cyan
+
+ & node $cli validate $d.Type $spec --quality showcase
+ if ($LASTEXITCODE -ne 0) { $failures += "validate:$($d.Base)"; continue }
+
+ & node $cli deliver $d.Type $spec $html --quality showcase
+ if ($LASTEXITCODE -ne 0) { $failures += "deliver:$($d.Base)"; continue }
+
+ if ($SkipVisualCheck) { $skipped += $d.Base; continue }
+
+ & node $cli visual-check $html
+ # exit 2 = skipped (Chrome/Chromium 부재). 실패와 구분해 기록한다.
+ if ($LASTEXITCODE -eq 2) { $skipped += $d.Base }
+ elseif ($LASTEXITCODE -ne 0) { $failures += "visual-check:$($d.Base)" }
+}
+
+Write-Host "`n=== manifest ===" -ForegroundColor Cyan
+& python (Join-Path $PSScriptRoot 'update_diagram_manifest.py')
+if ($LASTEXITCODE -ne 0) { $failures += 'manifest' }
+
+Write-Host ''
+if ($skipped.Count -gt 0) {
+ Write-Host "VISUAL-CHECK SKIPPED (no browser evidence): $($skipped -join ', ')" -ForegroundColor Yellow
+}
+if ($failures.Count -gt 0) {
+ Write-Host "REBUILD FAILED: $($failures -join ', ')" -ForegroundColor Red
+ exit 1
+}
+Write-Host 'REBUILD OK - run verify.ps1 to confirm the diagram gate' -ForegroundColor Green
+exit 0
diff --git a/scripts/update_diagram_manifest.py b/scripts/update_diagram_manifest.py
new file mode 100644
index 0000000..ab8882f
--- /dev/null
+++ b/scripts/update_diagram_manifest.py
@@ -0,0 +1,93 @@
+"""docs/diagrams/manifest.json 을 산출물 실제 바이트에서 다시 쓴다.
+
+수신증의 단일 작성자다. PowerShell 과 Python 이 각자 JSON 을 쓰면 들여쓰기·키 순서가
+갈려 매니페스트가 왔다 갔다 하므로, rebuild-diagrams.ps1 도 이 스크립트를 부른다.
+
+단독 실행도 된다: python scripts/update_diagram_manifest.py
+"""
+
+from __future__ import annotations
+
+import hashlib
+import io
+import json
+import re
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+DIAGRAM_DIR = ROOT / "docs" / "diagrams"
+
+# (타입, 파일 basename). 다이어그램을 추가하면 여기와 tests/test_diagram_sync.py 의
+# NODE_COLLECTION 을 함께 고친다.
+DIAGRAMS = [
+ ("architecture", "system-architecture"),
+ ("dataflow", "evidence-dataflow"),
+ ("workflow", "operator-review-workflow"),
+ ("lifecycle", "submission-lifecycle"),
+]
+
+NOTE = (
+ "archify deliver 는 바이트 결정적이다. 이 수신증은 tests/test_diagram_sync.py 가 "
+ "강제한다 - HTML 을 직접 고치거나 spec 만 고치고 재생성을 잊으면 커밋 게이트가 막는다. "
+ "갱신은 손으로 하지 말고 scripts/rebuild-diagrams.ps1 로 한다."
+)
+
+
+def _sha256(path: Path) -> str:
+ return hashlib.sha256(path.read_bytes()).hexdigest()
+
+
+def _generator_stamp(html: Path) -> str:
+ match = re.search(
+ r' int:
+ entries = []
+ for diagram_type, base in DIAGRAMS:
+ spec = DIAGRAM_DIR / f"{base}.{diagram_type}.json"
+ html = DIAGRAM_DIR / f"{base}.html"
+ for path in (spec, html):
+ if not path.exists():
+ raise SystemExit(f"없는 파일: {path}")
+ entries.append(
+ {
+ "type": diagram_type,
+ "spec": spec.name,
+ "html": html.name,
+ "specSha256": _sha256(spec),
+ "htmlSha256": _sha256(html),
+ "htmlBytes": html.stat().st_size,
+ }
+ )
+
+ generators = {_generator_stamp(DIAGRAM_DIR / e["html"]) for e in entries}
+ if len(generators) != 1:
+ raise SystemExit(
+ f"산출물마다 generator 가 다르다: {sorted(generators)} - 전부 재생성해야 한다."
+ )
+
+ manifest = {
+ "note": NOTE,
+ "generator": generators.pop(),
+ "diagrams": entries,
+ }
+ target = DIAGRAM_DIR / "manifest.json"
+ with io.open(target, "w", encoding="utf-8", newline="\n") as handle:
+ json.dump(manifest, handle, ensure_ascii=False, indent=2)
+ handle.write("\n")
+
+ # 비ASCII 는 stdout 에 넣지 않는다 (콘솔이 cp949 라 UnicodeEncodeError 로 죽은 이력).
+ print("manifest updated: %d diagrams, generator %s" % (len(entries), manifest["generator"]))
+ for entry in entries:
+ print(" %-30s %s.. %d B" % (entry["html"], entry["htmlSha256"][:8], entry["htmlBytes"]))
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tests/test_diagram_sync.py b/tests/test_diagram_sync.py
new file mode 100644
index 0000000..03cbe42
--- /dev/null
+++ b/tests/test_diagram_sync.py
@@ -0,0 +1,130 @@
+"""다이어그램 소스(JSON)와 산출물(HTML)이 갈라지지 않았는지 검사하는 커밋 게이트.
+
+`docs/diagrams/*.json` 이 소스이고 `*.html` 이 archify deliver 산출물이다. 둘의
+동기화를 확인하는 자동 장치가 없어서, spec 만 고치고 재생성을 잊거나 HTML 을
+직접 고쳐도 커밋이 통과했다. 폐쇄망 납품물에 동봉되는 문서라 고객이 보는 그림과
+실제 코드가 갈라져도 검출 경로가 없었다.
+
+이 테스트는 **브라우저도 Node 도 archify 도 필요 없다** — 순수 파일 비교다.
+archify deliver 가 바이트 결정적이라는 사실(같은 spec 에서 항상 같은 HTML)이
+그 근거이고, 그래서 수신증 대조만으로 충분하다.
+
+실패했을 때 고치는 법은 manifest 를 손으로 맞추는 게 아니라
+`scripts/rebuild-diagrams.ps1` 를 돌리는 것이다.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import re
+from pathlib import Path
+
+import pytest
+
+ROOT = Path(__file__).resolve().parents[1]
+DIAGRAM_DIR = ROOT / "docs" / "diagrams"
+MANIFEST = DIAGRAM_DIR / "manifest.json"
+
+# spec 의 어느 배열이 노드를 담는지는 다이어그램 타입마다 다르다.
+NODE_COLLECTION = {
+ "architecture": "components",
+ "dataflow": "nodes",
+ "workflow": "nodes",
+ "lifecycle": "states",
+}
+
+
+def _sha256(path: Path) -> str:
+ return hashlib.sha256(path.read_bytes()).hexdigest()
+
+
+def _manifest() -> dict:
+ return json.loads(MANIFEST.read_text(encoding="utf-8"))
+
+
+def _entries() -> list[dict]:
+ return _manifest()["diagrams"]
+
+
+def _ids() -> list[str]:
+ return [entry["html"] for entry in _entries()]
+
+
+def test_manifest_covers_every_delivered_diagram():
+ """새 다이어그램을 추가하면 수신증에도 등재해야 한다."""
+ # 사이드카(*.visual-check.html)는 검증 증거이지 납품물이 아니다.
+ delivered = {
+ path.name
+ for path in DIAGRAM_DIR.glob("*.html")
+ if ".visual-check." not in path.name
+ }
+
+ assert delivered == set(_ids()), (
+ "manifest.json 과 실제 산출물 목록이 다르다. "
+ "다이어그램을 추가/제거했으면 scripts/rebuild-diagrams.ps1 로 갱신한다."
+ )
+
+
+@pytest.mark.parametrize("entry", _entries(), ids=_ids())
+def test_html_matches_its_receipt(entry: dict):
+ """HTML 을 직접 고치면 여기서 막힌다."""
+ html = DIAGRAM_DIR / entry["html"]
+
+ assert html.exists(), f"{entry['html']} 이 없다"
+ assert html.stat().st_size == entry["htmlBytes"]
+ assert _sha256(html) == entry["htmlSha256"], (
+ f"{entry['html']} 이 수신증과 다르다. HTML 을 직접 고쳤거나 재생성 후 "
+ "manifest 를 갱신하지 않았다 — scripts/rebuild-diagrams.ps1 로 재생성한다."
+ )
+
+
+@pytest.mark.parametrize("entry", _entries(), ids=_ids())
+def test_spec_matches_its_receipt(entry: dict):
+ """spec 만 고치고 재생성을 잊으면 여기서 막힌다."""
+ spec = DIAGRAM_DIR / entry["spec"]
+
+ assert spec.exists(), f"{entry['spec']} 이 없다"
+ assert _sha256(spec) == entry["specSha256"], (
+ f"{entry['spec']} 이 수신증과 다르다. spec 을 고쳤으면 "
+ "scripts/rebuild-diagrams.ps1 로 HTML 을 재생성해야 한다."
+ )
+
+
+@pytest.mark.parametrize("entry", _entries(), ids=_ids())
+def test_every_spec_node_is_present_in_the_html(entry: dict):
+ """sha 대조가 통과해도 이 검사가 소스↔산출물의 의미적 연결을 잡는다.
+
+ 해시는 '둘 다 최신인가'만 말해준다. 이 검사는 산출물이 정말 그 spec 에서
+ 나왔는지를 본다 — 노드 id 는 HTML 에 data-node-id 로 그대로 박힌다.
+ """
+ spec = json.loads((DIAGRAM_DIR / entry["spec"]).read_text(encoding="utf-8"))
+ html = (DIAGRAM_DIR / entry["html"]).read_text(encoding="utf-8")
+
+ collection = NODE_COLLECTION[entry["type"]]
+ node_ids = [node["id"] for node in spec[collection]]
+ assert node_ids, f"{entry['spec']} 에 노드가 없다"
+
+ missing = [
+ node_id
+ for node_id in node_ids
+ if f'data-node-id="{node_id}"' not in html
+ ]
+ assert not missing, (
+ f"{entry['html']} 에 spec 노드 {missing} 가 없다 — 재생성되지 않은 산출물이다."
+ )
+
+
+def test_generator_version_is_recorded():
+ """산출물은 archify 버전에 따라 바이트가 달라진다. 그 버전이 기록돼 있어야
+ 나중에 '코드는 그대로인데 해시가 바뀐' 원인을 구분할 수 있다."""
+ recorded = _manifest()["generator"]
+ stamped = re.search(
+ r'