diff --git a/CHANGELOG.md b/CHANGELOG.md
index 68d06e9..9fbadd5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,63 @@
---
+## 1.3.0 — 2026-09-25 · 읽기 감사 · 증거 보존 · 감시자 감시
+
+이해관계자 5인(작업자·노조·안전관리자·공장장·설치기사) 관점으로 제품을 다시 검증한 결과
+나온 네 가지입니다. 넷 다 같은 모양이었습니다 — **구현도 테스트도 초록인데, 그 기능이
+존재해야 할 이유가 닿는 지점에서 아무 일도 일어나지 않는 상태.**
+
+### 추가 — 열람·반출 감사기록
+
+이 시스템은 쓰기를 전부 감사했습니다. 임계값 한 번 바꾼 것까지 해시 체인에 들어갑니다.
+그런데 **작업자 사진을 열어보는 것은 하나도 남지 않았습니다.**
+
+감시 시스템과 안전 시스템을 가르는 선이 정확히 거기입니다. 나머지 프라이버시 설계
+(RAM 링버퍼 7.5초·전송 전 얼굴 블러·라이브뷰 부재)가 전부 이 한 줄이 없어서 검증 불가능한
+*주장* 에 머물러 있었습니다. 교섭에서 "지난달에 우리 조합원 사진 누가 봤습니까"에 답이
+없으면 "안 봤습니다"는 증명이 아니라 주장입니다.
+
+- 스냅샷 열람 → 감사 동작 `snapshot_view` (세션당 사진 1회)
+- 라벨 데이터셋 대량 반출 → `label_export` (건수·기간과 함께)
+
+### 추가 — 증거 보존 보류 (`hold_until`)
+
+스냅샷은 나이만 보고 지워졌습니다. 기본 7일인데 중대재해 조사는 몇 주에서 몇 달이라,
+**우리가 내건 목적("사고 후 원인 분석")이 8일째에 스스로 사라지는** 상태였습니다.
+
+admin 만, 건별로, 최대 1년, 감사기록과 함께 보류를 겁니다. 확인 처리만으로 자동 연장되지
+않습니다 — 자동이면 사실상 전량 보존이 되어 7일 약속이 무너집니다. 만료되면 사람이 해제하지
+않아도 다시 삭제 대상이 됩니다. 사건 상세 화면에 버튼이 생깁니다.
+
+### 추가 — 통보 디스패처 생존 표시
+
+죽은 노드는 스스로 신고할 수 없어서 크론이 하트비트의 **부재**로 판정합니다. 그런데 그
+크론이 멈추면 아무도 판정하지 않습니다 — 통보·5분 미확인 확대·장치 이상 발행이 전부 그 한
+프로세스에 걸려 있어서, 멈춘 순간부터 화면은 조용해지고 **그 침묵은 "사고 없음"과 구별되지
+않습니다.** 관리자 화면이 이에 대해 하던 일은 "크론으로 실행 중이어야 합니다"라는 안내문
+한 줄이었습니다.
+
+이제 크론이 매 실행마다 시각을 남기고, 대시보드가 5분 넘게 갱신이 없으면 배너를 띄웁니다.
+감시자의 생존은 감시자가 아닌 쪽이 읽어야 하기 때문입니다.
+현장 경광등은 GPIO 직결이라 이 상황에서도 영향받지 않습니다.
+
+### 변경 — 체류 상한 초과에는 사진을 붙이지 않습니다
+
+증거 프레임은 `ergo_report` 하나만 빼고 **모든** 이벤트에 붙었습니다. 체류 상한 초과
+(`overstay`)도 포함이라, 밀폐공간 프리셋 기본값(30분) 기준으로 **정상 작업자가 규정대로
+일하는 동안 주기적으로 본인 사진이 서버로 가는** 상태였습니다. 위험해서가 아니라 오래
+있어서.
+
+기준을 명문화했습니다 — **시간 경과 자체가 조건인 이벤트에는 사진을 붙이지 않는다.**
+규칙 위반(단독작업·정원 초과)은 사진이 곧 위반의 증명이므로 그대로 둡니다.
+
+### 마이그레이션
+
+`2026-09-25-evidence-hold-and-view-audit.sql` — `move_events.hold_until` 컬럼과
+`move_app_state` 표. README-UPLOAD.md 의 10번입니다.
+
+---
+
## 1.2.1 — 2026-09-21 · 납품 정비 (기능 변경 없음)
감지 동작은 1.2.0 과 같습니다. 납품·운영에서 손으로 하던 일 세 가지를 닫았습니다.
diff --git a/VERSION b/VERSION
index 6085e94..f0bb29e 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.2.1
+1.3.0
diff --git a/docs/INDEX.md b/docs/INDEX.md
index 75ba214..3cae797 100644
--- a/docs/INDEX.md
+++ b/docs/INDEX.md
@@ -194,3 +194,29 @@
(회전 도구 추가) 버전을 올렸다. 산출물 `move-detect-cafe24-1.2.1-20260921.zip` ·
`move-detect-pi-1.2.1-20260921-aarch64-cp311-hailo8.tar.gz`(91MB, MoveNet 폴백 포함)
+## 2026-09-25 — 1.3.0 (이해관계자 페르소나 검증 4건)
+
+앞선 감사 네 번은 "이 코드가 제대로 도는가"를 물었다. 이번엔 작업자·노조·안전관리자·공장장·
+설치기사 5인 관점으로 3단논법을 세우고 전제를 코드로 확인한 뒤 반문으로 깼다. **5인 중 2인의
+논증이 완전히, 2인이 부분적으로 무너졌다.** 넷 다 "구현도 테스트도 초록인데 그 기능이 지켜야
+할 것이 안 지켜지는" 같은 모양이었다.
+
+- **열람 감사** — 쓰기는 임계값 변경까지 전부 해시 체인에 남는데 **사진 열람은 하나도 남지
+ 않았다.** 감시 시스템과 안전 시스템을 가르는 선이 거기라서, 나머지 프라이버시 설계
+ (RAM 링버퍼·전송 전 블러·라이브뷰 부재)가 전부 검증 불가능한 주장이었다. `snapshot_view`
+ (세션당 1회) · `label_export`(건수·기간). 거부된 열람은 기록하지 않는다 — 그러면 감사 로그가
+ "본 사람"이 아니라 "요청한 사람" 목록이 되어 정작 쓸 수 없다
+- **크론 생존** — 통보·5분 확대·`device_fault` 발행이 전부 한 프로세스에 걸려 있는데, 그게
+ 멈추면 화면이 조용해지고 **그 침묵이 "사고 없음"과 구별되지 않았다.** 관리자 화면의 조치는
+ 산문 안내 한 줄. 이제 크론이 실행 시각을 남기고 대시보드가 그 나이를 읽는다(5분 기준)
+- **증거 보존 보류** — 스냅샷 삭제가 나이만 봤다. 기본 7일인데 중대재해 조사는 몇 주~몇 달이라
+ 우리가 내건 목적이 8일째에 사라졌다. admin 만·건별·최대 1년·감사기록과 함께.
+ 확인 처리로 자동 연장하지 않는다 — 자동이면 사실상 전량 보존이라 7일 약속이 무너진다
+- **증거 정책** — 프레임이 `ergo_report` 빼고 **모든** 이벤트에 붙었다. 체류 상한 초과 포함이라
+ 밀폐공간 기본값(30분) 기준 정상 작업자가 규정대로 일하는 동안 주기적으로 사진이 갔다.
+ 기준 명문화: **시간 경과 자체가 조건인 이벤트에는 사진을 붙이지 않는다**
+- 마이그레이션 `2026-09-25-evidence-hold-and-view-audit.sql`(README-UPLOAD 10번).
+ 노조 대응 문서의 "가장 강한 카드가 비어 있다"던 항목이 닫혔고, 7일 원칙의 예외(보류)를
+ 정직하게 적었다
+- 산출물 `move-detect-cafe24-1.3.0-20260925.zip` · `move-detect-pi-1.3.0-20260925-aarch64-cp311-hailo8.tar.gz`
+
diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md
index 29ef966..c2f437e 100644
--- a/docs/PROJECT_MAP.md
+++ b/docs/PROJECT_MAP.md
@@ -55,6 +55,8 @@
- `tools/`: `dispatch_alerts.php`(크론 1분) · `maintain.php`(크론 일 1회, 보존기간) ·
`backup.php`(DB 덤프+스냅샷) · `verify_audit_chain.php`(감사 무결성) · `seed_demo.php`(시연·QA) ·
`rotate_credentials.php`(장치 HMAC 키·관리자 비번 회전 — DB 미접속, 붙여넣을 SQL 만 출력).
+ `dispatch_alerts.php` 는 매 실행마다 `move_app_state['dispatch_alerts.last_run']` 을 남기고,
+ 대시보드(`/api/devices` → `dispatcher`)가 그 나이를 읽어 크론이 멈춘 것을 표시한다.
전부 CLI 전용(`bootstrap.php` 가 SAPI 검사), 납품 시 `move_detect_app/tools/` 로 www 밖에 배치
- 라우트: `POST /api/events`(ingest, HMAC) · `POST /api/heartbeat` · `GET /api/config` ·
`GET /api/events` · `GET /api/devices` · `GET /api/heatmap/{camera}`(spatial 집계) ·
diff --git a/docs/delivery-audit.md b/docs/delivery-audit.md
index bcd4a74..0111b24 100644
--- a/docs/delivery-audit.md
+++ b/docs/delivery-audit.md
@@ -1,9 +1,10 @@
# Delivery Audit — move-detect (납품 전 결함 목록 · 순차 처리)
-> **판정 (2026-09-21, 1.2.1): 적합 — 오프하드웨어 항목 전량 해소.**
+> **판정 (2026-09-25, 1.3.0): 적합 — 오프하드웨어 항목 전량 해소.**
> 1차 43/43 + 2차 12/12 + 3차(1.1.0 장치 건전성) 14/14 +
> **4차 재감사(1.2.0 추락·동시 다발·프리셋 표면) 23/23** 해소.
> 2026-09-21 납품 정비로 Pi 번들 재빌드·MoveNet 원본·자격증명 회전 도구까지 닫혔다.
+> **5차 이해관계자 페르소나 검증(2026-09-25) 4/4** — 열람 감사·크론 생존·증거 보존·증거 정책.
> 잔여는 실장비(OFF-HW) 검증과 **운영자가 직접 해야 하는 자격증명 교체** 뿐.
Source: 62-agent adversarial audit (2026-07-16) + skill `iot-airgap-delivery` §3 + manual
@@ -532,6 +533,54 @@ config 템플릿 2종. `edge/` 는 불변. 4개 관점 병렬 감사 후 각 발
---
+## 5차 — 이해관계자 페르소나 검증 (2026-09-25, 1.3.0)
+
+앞선 네 번은 전부 "이 코드가 제대로 도는가"를 물었다. 이번에는 **"이 기능이 존재해야 할
+이유가 실제로 닿는가"** 를 물었다 — 작업자·노조 대표·안전관리자·공장장·설치기사 5인의
+관점에서 각각 3단논법을 세우고, 전제를 코드로 확인한 뒤 반문으로 깼다.
+
+5인 중 2인의 논증이 완전히 무너졌고 2인이 부분적으로 무너졌다. 넷 다 같은 모양이었다:
+구현도 단위테스트도 초록인데, **그 기능이 지켜야 할 것이 지켜지지 않는 상태.**
+
+- [x] **S5-1 [B]** 스냅샷 열람·대량 반출이 감사기록에 남지 않았다 (`SnapshotProxy::serve`,
+ `ExportController::labels`). 쓰기는 임계값 변경까지 전부 해시 체인에 남는데 읽기는 하나도
+ 남지 않았다 — 감시 시스템과 안전 시스템을 가르는 선이 정확히 거기고, 나머지 프라이버시
+ 설계 전부가 이 한 줄이 없어서 검증 불가능한 주장에 머물러 있었다.
+ DONE — `snapshot_view`(세션당 사진 1회) · `label_export`(건수·기간). 거부된 열람은
+ 기록하지 않는다(감사 로그가 "본 사람"이 아니라 "요청한 사람" 목록이 되면 쓸 수 없다).
+ 기록 실패가 사진 열람을 막지 않되 세션 메모에 넣지 않아 다음 요청이 재시도한다.
+ 테스트 `ReadAuditTest` 8건.
+
+- [x] **S5-2 [B]** 통보 크론이 멈춘 것을 아무도 감지하지 못했다. 통보·5분 확대·`device_fault`
+ 발행이 전부 `dispatch_alerts.php` 한 프로세스에 걸려 있는데, 그게 멈추면 화면은 조용해지고
+ 그 침묵이 "사고 없음"과 구별되지 않는다. 관리자 화면의 조치는 산문 안내 한 줄이었다.
+ DONE — 크론이 `move_app_state` 에 실행 시각을 남기고 `/api/devices` 가 그 나이를 읽는다
+ (5분 기준). "한 번도 안 돎"과 "멈춤"을 구별한다 — 조치가 다르기 때문이다. 마이그레이션 전
+ 배포에서 상태 패널 전체가 죽지 않도록 예외를 삼킨다. 테스트 `DispatcherLivenessTest`.
+
+- [x] **S5-3 [H]** 스냅샷 삭제가 나이만 봤다(`purgeOlderThan`). 기본 7일인데 중대재해 조사는
+ 몇 주~몇 달이라, 우리가 내건 목적("사고 후 원인 분석")이 8일째에 스스로 사라졌다.
+ DONE — `move_events.hold_until`. admin 만, 건별로, 최대 1년, 감사기록과 함께. 확인 처리로
+ 자동 연장하지 않는다(자동이면 사실상 전량 보존이 되어 7일 약속이 무너진다). 고아 스냅샷은
+ LEFT JOIN 으로 여전히 삭제 대상 — INNER JOIN 이면 영원히 안 지워지는 PIPA 누출이 된다.
+ 테스트 `EvidenceHoldTest` 8건.
+
+- [x] **S5-4 [H]** 증거 프레임이 `ergo_report` 하나만 빼고 **모든** 이벤트에 붙었다. 체류 상한
+ 초과(`overstay`)도 포함이라, 밀폐공간 프리셋 기본값(30분) 기준으로 정상 작업자가 규정대로
+ 일하는 동안 주기적으로 본인 사진이 서버로 갔다 — 위험해서가 아니라 오래 있어서.
+ DONE — `edge/app.py` `NO_EVIDENCE_TYPES`. 기준은 "시간 경과 자체가 조건인 이벤트에는
+ 사진을 붙이지 않는다". 규칙 위반(단독작업·정원 초과)은 사진이 곧 위반의 증명이라 유지.
+ `run_once` 를 실제로 태워 같은 배치에서 한쪽은 증거를 들고 다른 쪽은 안 드는지 본다.
+
+### 이번 검증에서 확인한 잔여 (코드로 닫을 수 없음)
+
+- **노조 대표 열람 계정** — 감사기록이 생겼으므로 이제 의미를 갖는다. 계정 발급은 사업주 결정.
+- **보관기간 화면 표시** — 설정 파일 안이 아니라 눈에 보이는 곳에. 다음 UI 개정에서.
+- **SHA256SUMS 미서명** — `SIGN_KEY` 미설정이라 손상은 잡고 위변조는 못 잡는다. 폐쇄망 USB
+ 전달 경로의 위협 모델에서는 수용 가능하고 문서에 정직하게 적혀 있다. 결정으로 기록한다.
+
+---
+
## OFF-HW 잔여 (실장비 커미셔닝에서만 검증 가능)
코드·문서로 닫을 수 없는 항목. 납품 판정을 막지 않되, 현장 설치 시 반드시 수행한다.
diff --git a/docs/영상보관-노조대응.md b/docs/영상보관-노조대응.md
index 7bf9eb5..49d77c6 100644
--- a/docs/영상보관-노조대응.md
+++ b/docs/영상보관-노조대응.md
@@ -13,7 +13,9 @@
|---|---|---|---|
| 프레임 링버퍼 | **엣지 노드 RAM에만** | **90프레임 = 7.5초**(기본 12fps). 계속 덮어씀. **디스크에 쓰지 않음** | `edge/snapshot.py` `RingBuffer(maxlen=90)`, `TARGET_FPS=12` |
| 증거 스냅샷 | 서버 | **이벤트가 발생했을 때만 3장** — 이벤트 **2.5초 전·1초 전·그 순간**, 얼굴 블러 후 JPEG | `edge/app.py` `SNAPSHOT_OFFSETS_S = (2.5, 1.0, 0.0)` |
-| 스냅샷 보관 | 서버 | **기본 7일 후 자동 삭제** | `Config::snapshotRetentionDays() = 7` |
+| 스냅샷 보관 | 서버 | **기본 7일 후 자동 삭제**. 예외는 아래 '보존 보류' 한 가지 | `Config::snapshotRetentionDays() = 7` |
+| 스냅샷 **열람** | 서버 | **누가 언제 어느 사진을 열었는지 감사기록에 남음** | `SnapshotProxy::recordView()`, 감사 동작 `snapshot_view` |
+| 기록 **반출** | 서버 | 대량 내보내기도 건수와 함께 기록됨 | `ExportController`, 감사 동작 `label_export` |
| 이벤트 기록(텍스트) | 서버 | 기본 영구 — **개인 식별 정보 없음** | `Config::eventRetentionDays() = 0` |
> **"7.5초를 보관하는 것 아니냐"** — 그 7.5초는 열람할 수 있는 화면도 API도 없고, 12분의 1초마다
@@ -106,22 +108,24 @@
- **위·변조 탐지 감사기록** — 설정 변경·확인·오탐 처리가 해시 체인으로 남아
지우거나 고치면 검증에서 드러납니다
- **스냅샷 7일 자동 삭제**, 이후에는 통계와 텍스트 기록만 남음
+- **열람도 기록됩니다** — 누가 언제 어느 사진을 열었는지가 같은 해시 체인에 남습니다.
+ 쓰기만 남기고 읽기는 안 남기면 "안 봤다"는 주장에 그치는데, 이제 기록으로 답할 수 있습니다
+- **오래 머물렀다는 이유로는 사진이 남지 않습니다** — 체류 상한 초과는 시간 경과만으로
+ 발화하므로 증거 프레임을 붙이지 않습니다. 사진이 남는 것은 사람이 쓰러졌거나 위험구역에
+ 들어갔거나 2인1조가 깨졌을 때입니다 (`edge/app.py` `NO_EVIDENCE_TYPES`)
-### 아직 없는 것 — 보완을 권고합니다
+### 남은 것 — 운영으로 해야 하는 두 가지
-**스냅샷을 누가 열람했는지가 감사기록에 남지 않습니다.** 현재 감사기록은 쓰기
-동작(확인·오탐·구역 변경·설정 변경·로그인)만 기록합니다(`server/src/**` 의
-`audit->append` 호출부 기준).
+열람 감사기록은 **구현되었습니다**(2026-09-25). 이 문서가 "가장 강한 카드가 비어 있다"고
+지적했던 항목이고, 이제 누가 언제 어느 사진을 열었는지가 설정 변경과 같은 해시 체인에
+남습니다. 대량 반출도 건수와 함께 기록됩니다.
-이것은 노조 협의에서 **가장 강한 카드가 될 수 있는 항목이 비어 있는 것**입니다.
-"아무도 함부로 보지 않는다"를 약속이 아니라 **기록으로** 만들 수 있습니다.
+코드로 닫을 수 없는 두 가지가 남습니다 — 둘 다 사업주의 결정입니다.
-권고 3가지:
-
-1. **스냅샷 열람을 감사기록에 남긴다** — 누가, 언제, 어느 이벤트의 사진을 열었는지.
-2. **노조 대표에게 열람 계정(viewer)과 감사기록 조회 권한을 준다** —
+1. **노조 대표에게 열람 계정(viewer)과 감사기록 조회 권한을 준다** —
감시자를 감시할 수 있게 하는 것이 불신을 푸는 가장 빠른 길입니다.
-3. **보관기간을 화면에 표시한다** — 설정 파일 안이 아니라 눈에 보이는 곳에.
+ 기록이 생겼으므로 이제 이 권한이 실제로 의미를 가집니다.
+2. **보관기간을 화면에 표시한다** — 설정 파일 안이 아니라 눈에 보이는 곳에.
---
@@ -150,6 +154,25 @@
노조에게는 "사진은 일주일이면 사라지고, 남는 것은 몇 시에 무슨 일이 있었다는
기록뿐"이라고 설명할 수 있고, 사업주에게는 보존 의무를 충족한다고 설명할 수 있습니다.
+#### 예외 하나 — 사고로 확정된 건의 증거 보존 (2026-09-25 추가)
+
+중대재해 조사는 몇 주에서 몇 달입니다. 7일 규칙만 있으면 **조사가 시작되기도 전에 사진이
+사라져**, 이 시스템을 도입한 목적("사고 후 원인 분석") 자체가 성립하지 않습니다.
+
+그래서 **명시적 보존 보류**를 둡니다. 이것이 7일 원칙의 훼손이 아닌 이유:
+
+| | 내용 |
+|---|---|
+| 걸 수 있는 사람 | **admin 만** (`manage_compliance`). 현장 운영자는 못 겁니다 |
+| 대상 | 보류를 건 **그 사건 한 건**. 전체 보존으로 바뀌지 않습니다 |
+| 기간 | 화면 기본 90일, **최대 1년**. 무기한 보류는 아예 불가능합니다 |
+| 기록 | 누가 언제 얼마나 걸었는지 · 해제했는지가 감사 체인에 남습니다 |
+| 만료 후 | 자동으로 다시 삭제 대상이 됩니다. 사람이 해제하지 않아도 됩니다 |
+
+노조에는 이렇게 설명할 수 있습니다 — **"전부 지웁니다. 예외는 사고로 확정돼 조사가 걸린
+건뿐이고, 그것도 최대 1년이며, 누가 걸었는지가 기록에 남습니다."** 확인 처리만으로 자동
+연장되지 않는다는 점이 중요합니다: 자동이면 사실상 전량 보존이 되어 7일 약속이 무너집니다.
+
### 5-3. 노조 협의는 이기고 지는 문제가 아니라 절차 문제입니다
**근로자참여 및 협력증진에 관한 법률 제20조**는 **"사업장 내 근로자 감시 설비의 설치"**
diff --git a/docs/프로젝트-소개서.html b/docs/프로젝트-소개서.html
index ff675ad..90e407c 100644
--- a/docs/프로젝트-소개서.html
+++ b/docs/프로젝트-소개서.html
@@ -139,7 +139,7 @@ pre code { background: transparent; color: inherit; padding: 0; }
담당자 통보까지 이어지도록 만든 온프레미스 안전관리 시스템입니다. 영상은 외부로 나가지
않습니다 — 사고 순간의 스냅샷 3장(얼굴 자동 블러)만 전달합니다.
-
버전 1.2.1 · 2026-09-21
+
버전 1.3.0 · 2026-09-25
1. 왜 만들었나
작업장에 설치된 카메라와 현장 경광등.
diff --git a/docs/프로젝트-소개서.md b/docs/프로젝트-소개서.md
index 1f05673..2a489bc 100644
--- a/docs/프로젝트-소개서.md
+++ b/docs/프로젝트-소개서.md
@@ -6,7 +6,7 @@
> 담당자 통보까지 이어지도록** 만든 온프레미스 안전관리 시스템입니다. 영상은 외부로 나가지
> 않습니다 — 사고 순간의 스냅샷 3장(얼굴 자동 블러)만 전달합니다.
-버전 1.2.1 · 2026-09-21
+버전 1.3.0 · 2026-09-25
---
diff --git a/edge/app.py b/edge/app.py
index 1519778..e27cd60 100644
--- a/edge/app.py
+++ b/edge/app.py
@@ -14,6 +14,27 @@ from edge.events import Event, EventType, severity_for
# The server accepts at most 3 snapshots per event (EventValidator), so this is the cap.
SNAPSHOT_OFFSETS_S = (2.5, 1.0, 0.0)
+# 사진을 붙이지 않는 유형.
+#
+# 기준은 "이 사진이 사건의 증거인가, 그 사람이 거기 있었다는 기록인가"이다. 넘어짐·추락·협착은
+# 무슨 일이 일어났는지가 프레임 안에 있고, 단독작업·정원 초과는 규칙 위반 자체가 인원 수라서
+# 사진이 그 수를 증명한다(관리자가 오탐인지 확인하는 수단이기도 하다). 아래 넷은 다르다:
+#
+# ERGO_REPORT — 카메라 단위 익명 통계. 프레임을 붙이면 통계가 감시 영상이 된다.
+# OVERSTAY — 발화 조건이 시간 경과 하나뿐이다. "정상 작업을 오래 했다"는 사건이 아니고,
+# 사진이 말해 주는 것은 그게 누구였고 무엇을 입었는지뿐이다. 그 사람이 실제로
+# 쓰러지면 fall/no_motion 이 따로 발화하고 그쪽에는 증거가 붙는다.
+# DEVICE_FAULT / MONITORING_DEGRADED — 장치 상태. 사람이 찍힐 이유가 없다.
+#
+# 이 목록이 짧게 유지되는 것이 중요하다. "정상 근무 중에는 아무것도 남지 않는다"는 우리가
+# 작업자에게 한 약속인데, 시간이 조건인 유형에 사진을 붙이는 순간 그 약속이 거짓이 된다.
+NO_EVIDENCE_TYPES = frozenset({
+ EventType.ERGO_REPORT,
+ EventType.OVERSTAY,
+ EventType.DEVICE_FAULT,
+ EventType.MONITORING_DEGRADED,
+})
+
def _head_boxes(persons: list) -> list:
# Head region for face anonymization (PIPA). Sized from the PERSON bbox, not a fixed
@@ -85,10 +106,8 @@ def run_once(source, pose, tracker, pool, engine, ring, snapshotter, sink, now_f
for tr in lost:
pool.forget(tr.track_id)
if events:
- # ERGO_REPORT is an anonymous aggregate (posture-exposure sums) — attaching a frame
- # would turn a statistic into surveillance imagery. Capture only when a real incident
- # needs evidence, and never attach snapshots to the ergo report itself.
- evidential = [e for e in events if e.event_type != EventType.ERGO_REPORT]
+ # 증거가 필요한 사건에만 프레임을 뜬다 — NO_EVIDENCE_TYPES 주석이 그 경계를 정한다.
+ evidential = [e for e in events if e.event_type not in NO_EVIDENCE_TYPES]
snaps = []
if evidential:
# Evidence timeline, oldest→now: how the incident DEVELOPED, not just its end
@@ -98,7 +117,7 @@ def run_once(source, pose, tracker, pool, engine, ring, snapshotter, sink, now_f
frames, boxes = ring.timeline(now, SNAPSHOT_OFFSETS_S)
snaps = snapshotter.capture(frames, boxes)
for e in events:
- sink.try_send(e, [] if e.event_type == EventType.ERGO_REPORT else snaps)
+ sink.try_send(e, [] if e.event_type in NO_EVIDENCE_TYPES else snaps)
return events
diff --git a/server/deploy/README-UPLOAD.md b/server/deploy/README-UPLOAD.md
index 02cb763..304ed99 100644
--- a/server/deploy/README-UPLOAD.md
+++ b/server/deploy/README-UPLOAD.md
@@ -96,6 +96,7 @@ DB 세 줄만 카페24 관리자 페이지의 MySQL 정보로 바꿉니다.
| 7 | `2026-08-08-actions-site.sql` | 개선 조치의 현장(site) 스코프 |
| 8 | `2026-08-09-safety-platform.sql` | 통보 채널·억제·위험성평가·인수인계·성능 시계열 표, 감사로그 해시체인, 신규 감지 유형 |
| 9 | `2026-09-04-fall-from-height.sql` | 추락(`fall_from_height`)·동시 다발 쓰러짐(`multi_down`) 유형 허용 |
+| 10 | `2026-09-25-evidence-hold-and-view-audit.sql` | 증거 보존 보류(`hold_until`) + 통보 크론 생존 기록(`move_app_state`) |
1. phpMyAdmin → 해당 DB → **SQL** 탭
2. 위 표의 순서대로 파일 내용을 **하나씩** 붙여넣고 실행합니다
diff --git a/server/migrations/2026-09-25-evidence-hold-and-view-audit.sql b/server/migrations/2026-09-25-evidence-hold-and-view-audit.sql
new file mode 100644
index 0000000..254528b
--- /dev/null
+++ b/server/migrations/2026-09-25-evidence-hold-and-view-audit.sql
@@ -0,0 +1,36 @@
+-- 1.3.0 — 증거 보존(legal hold) + 디스패처 생존 기록
+--
+-- 적용 조건: 2026-09-04-fall-from-height.sql 이후.
+--
+-- 왜 이 두 개가 한 마이그레이션인가: 둘 다 "조용한 실패"를 드러내는 같은 종류의 장치다.
+--
+-- 1) move_events.hold_until — 스냅샷 보존 보류
+-- 스냅샷은 보존기간이 지나면 나이만 보고 지워졌다. 중대재해 조사는 몇 주에서 몇 달인데
+-- 사진은 7일이면 사라진다. 우리가 노조와 고객에게 말해 온 목적("사고 후 원인 분석")이
+-- 8일째에 증발하는 상태였다. 보류는 명시적 조작이어야 하고(자동 연장은 결국 전량 보존이
+-- 되어 프라이버시 약속을 무너뜨린다) 감사에 남아야 한다.
+--
+-- 2) move_app_state — 크론이 마지막으로 돈 시각
+-- 죽은 노드는 스스로 신고할 수 없어서 크론이 하트비트의 부재로 판정한다. 그런데 그
+-- 크론이 죽으면 아무도 판정하지 않는다. 통보·확대·장치 이상 발행이 전부 한 프로세스에
+-- 걸려 있는데, 화면에는 "크론으로 실행 중이어야 합니다"라는 안내문만 있었다.
+-- 감시자의 생존은 감시자가 아닌 쪽(운영자의 브라우저)이 읽어야 하므로, 크론이 자기
+-- 실행 시각을 남기고 대시보드가 그 나이를 본다.
+
+ALTER TABLE move_events
+ ADD COLUMN hold_until BIGINT UNSIGNED NULL AFTER false_positive_at;
+
+-- 보존 정리는 "이 스냅샷의 이벤트가 보류 중인가"를 건마다 묻는다. 보류는 드물기 때문에
+-- 전체 스캔이 아니라 보류된 것만 찾는 인덱스가 맞다.
+CREATE INDEX ix_events_hold ON move_events (hold_until);
+
+CREATE TABLE move_app_state (
+ k VARCHAR(64) NOT NULL,
+ v VARCHAR(255) NOT NULL,
+ updated_at BIGINT UNSIGNED NOT NULL,
+ PRIMARY KEY (k)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- 검증 쿼리 — 아래 둘이 각각 1행, 0행을 내면 성공입니다.
+-- SHOW COLUMNS FROM move_events LIKE 'hold_until';
+-- SELECT * FROM move_app_state;
diff --git a/server/public/assets/app.js b/server/public/assets/app.js
index 1281d17..f08972a 100644
--- a/server/public/assets/app.js
+++ b/server/public/assets/app.js
@@ -992,6 +992,7 @@
})
.then(function (data) {
if (data && data.storage) renderStorage(data.storage);
+ if (data) renderDispatcher(data.dispatcher);
if (data && Array.isArray(data.devices)) {
deviceList = data.devices;
devicesLoaded = true;
@@ -1036,6 +1037,35 @@
+ '보존기간 설정을 줄이거나 용량을 확보하세요.'));
}
+ // --- notification dispatcher liveness -----------------------------------------------
+ // 죽은 노드는 스스로 신고할 수 없어서 크론이 하트비트의 부재로 판정한다. 그런데 그 크론이
+ // 멈추면 아무도 판정하지 않는다 — 통보·5분 확대·장치 이상 발행이 전부 그 한 프로세스에
+ // 걸려 있어서, 멈춘 순간부터 이 화면은 조용해지고 그 침묵은 "사고 없음"과 똑같이 보인다.
+ // 감시자의 생존은 감시자가 아닌 쪽이 읽어야 해서, 그 판정이 여기 있다.
+ var dispatchWarn = document.getElementById('dispatch-warn');
+
+ function renderDispatcher(d) {
+ if (!dispatchWarn) return;
+ if (!d || !d.stale) { dispatchWarn.hidden = true; return; }
+ dispatchWarn.hidden = false;
+ dispatchWarn.textContent = '';
+ var never = d.last_run_at === null || d.last_run_at === undefined;
+ dispatchWarn.appendChild(spanWith('warn-lead', never ? '통보가 설정되지 않았습니다' : '통보가 멈췄습니다'));
+ dispatchWarn.appendChild(document.createTextNode(never
+ ? ' — 통보 디스패처가 한 번도 실행된 적이 없습니다. 크론에 tools/dispatch_alerts.php 를 1분 주기로 등록하세요. '
+ + '등록 전까지 메일·웹훅·5분 미확인 확대·장치 이상 알림이 나가지 않습니다 (현장 경광등은 영향 없음).'
+ : ' — 마지막 실행 ' + fmtAgo(d.seconds_since) + ' 전 (기준 '
+ + Math.round(d.stale_after_s / 60) + '분). 이 상태에서는 화면이 조용한 것이 "사고 없음"이라는 뜻이 아닙니다. '
+ + '메일·웹훅·미확인 확대·장치 이상 알림이 전부 멈춰 있습니다 (현장 경광등은 영향 없음).'));
+ }
+
+ function fmtAgo(s) {
+ if (typeof s !== 'number') return '?';
+ if (s < 120) return s + '초';
+ if (s < 7200) return Math.floor(s / 60) + '분';
+ return Math.floor(s / 3600) + '시간';
+ }
+
// --- bulk acknowledge ----------------------------------------------------------------
// For clearing a backlog. Criticals are excluded ON PURPOSE and the button says so: a fall
// is exactly the row a human must look at, and a control that can clear one in a sweep is a
diff --git a/server/public/assets/detail.js b/server/public/assets/detail.js
index d7ebe1a..fc35905 100644
--- a/server/public/assets/detail.js
+++ b/server/public/assets/detail.js
@@ -158,11 +158,13 @@
.catch(function () { /* the POST surfaces it as a 403 */ });
}
- function post(path) {
- return fetch(B + '/events/' + encodeURIComponent(id) + path, {
- method: 'POST',
- headers: { 'X-Csrf-Token': csrfToken || '' }
- }).then(function (res) {
+ function post(path, body) {
+ var opt = { method: 'POST', headers: { 'X-Csrf-Token': csrfToken || '' } };
+ if (body !== undefined) {
+ opt.headers['Content-Type'] = 'application/json';
+ opt.body = JSON.stringify(body);
+ }
+ return fetch(B + '/events/' + encodeURIComponent(id) + path, opt).then(function (res) {
if (res.status === 401) { window.location.href = B + '/login'; return null; }
if (!res.ok) throw new Error('action_failed:' + res.status);
return res;
@@ -242,6 +244,60 @@
actionsBox.appendChild(fp);
}
+ // --- 증거 보존 보류 -------------------------------------------------------------------
+ // 스냅샷은 기본 7일 뒤 지워진다. 그 규칙이 없으면 작업자 사진이 영원히 쌓이므로 규칙 자체는
+ // 옳은데, 중대재해 조사는 몇 주에서 몇 달이라 조사가 시작되기도 전에 사진이 사라졌다.
+ //
+ // renderActions 와 분리한 이유: 저쪽은 확인·오탐이 끝나면 조기 반환한다. 보류가 필요한
+ // 상황은 바로 그 "이미 확인된 진짜 사고"라서, 같은 함수에 두면 정작 필요할 때 안 보인다.
+ var holdBox = document.getElementById('detail-hold');
+ var HOLD_DAYS = 90;
+
+ function renderHold() {
+ if (!holdBox || !currentEvent) return;
+ if (window.APP_ROLE !== 'admin') { holdBox.hidden = true; return; }
+ holdBox.hidden = false;
+ holdBox.textContent = '';
+
+ var until = currentEvent.hold_until;
+ var held = typeof until === 'number' && until * 1000 > Date.now();
+
+ var btn = document.createElement('button');
+ btn.type = 'button';
+ btn.className = held ? 'fp-btn' : 'detail-link-btn';
+ btn.textContent = held ? '보존 보류 해제' : '증거 보존 (' + HOLD_DAYS + '일)';
+ btn.setAttribute('aria-label', held
+ ? '증거 보존 보류를 해제합니다. 이후 보존기간이 지난 스냅샷은 정리 대상이 됩니다.'
+ : '이 사건의 스냅샷을 ' + HOLD_DAYS + '일간 보존기간 정리에서 제외합니다.');
+ btn.addEventListener('click', function () {
+ btn.disabled = true;
+ post('/hold', { days: held ? 0 : HOLD_DAYS })
+ .then(function (res) { return res ? res.json() : null; })
+ .then(function (d) {
+ if (!d) return;
+ currentEvent.hold_until = d.hold_until;
+ renderHold();
+ renderMeta();
+ })
+ .catch(function () {
+ btn.disabled = false;
+ var p = document.createElement('p');
+ p.className = 'detail-action-note is-error';
+ p.textContent = '보존 보류 처리에 실패했습니다.';
+ holdBox.appendChild(p);
+ });
+ });
+ holdBox.appendChild(btn);
+
+ var note = document.createElement('p');
+ note.className = 'detail-action-note';
+ note.textContent = held
+ ? '보존 보류 중 — ' + new Date(until * 1000).toLocaleDateString('ko-KR')
+ + '까지 이 사건의 사진이 자동 삭제되지 않습니다.'
+ : '조사가 진행 중이면 보존을 걸어두세요. 걸지 않으면 보존기간이 지난 사진은 자동으로 지워집니다.';
+ holdBox.appendChild(note);
+ }
+
function renderMeta() {
var ev = currentEvent || {};
meta.textContent = '';
@@ -374,6 +430,7 @@
renderHead();
renderMeta();
renderActions();
+ renderHold();
renderLinks();
renderSnapshots(data.snapshots);
}
diff --git a/server/public/index.php b/server/public/index.php
index 1f6a30e..19c5b71 100644
--- a/server/public/index.php
+++ b/server/public/index.php
@@ -42,7 +42,8 @@ use App\Auth\PhpSessionStore;
use App\Repository\Pdo\{PdoDeviceRepository, PdoNonceRepository, PdoRateLimiterRepository,
PdoEventRepository, PdoSnapshotRepository, PdoUserRepository, PdoZoneRepository,
PdoThresholdRepository, PdoAuditRepository, PdoActionRepository, PdoSuppressionRepository,
- PdoAlertRepository, PdoHandoverRepository, PdoRiskControlRepository, PdoDeviceMetricRepository};
+ PdoAlertRepository, PdoHandoverRepository, PdoRiskControlRepository, PdoDeviceMetricRepository,
+ PdoAppStateRepository};
use App\Ingest\IngestController;
use App\Api\{ConfigController, HeartbeatController};
use App\Auth\{AuthController, CsrfController};
@@ -93,6 +94,7 @@ $handovers = new PdoHandoverRepository($pdo);
$risks = new PdoRiskControlRepository($pdo);
$metrics = new PdoDeviceMetricRepository($pdo);
$actions = new PdoActionRepository($pdo);
+$appState = new PdoAppStateRepository($pdo);
$ingest = new IngestController($cfg, $devices, $nonces, $rate, $events, $snaps);
$config = new ConfigController($devices, $zones, $thr, $nonces, $cfg, $rate, $alerts);
@@ -101,13 +103,13 @@ $auth = new AuthController($users, $audit, $rate);
$csrfCtl = new CsrfController();
$list = new EventListController($events, $snaps, $sess);
$detail = new EventDetailController($events, $snaps, $audit, $sess);
-$proxy = new SnapshotProxy($snaps, $events, $sess);
+$proxy = new SnapshotProxy($snaps, $events, $sess, $audit);
$zoneCtl = new ZoneConfigController($zones, $devices, $audit, $sess, $snaps);
-$deviceStatus = new DeviceStatusController($devices, $sess, $metrics, $cfg);
+$deviceStatus = new DeviceStatusController($devices, $sess, $metrics, $cfg, $appState);
$heatmap = new HeatmapController($events, $sess);
$fpStats = new FpStatsController($events, $sess);
$report = new ReportController($events, $sess);
-$export = new ExportController($events, $snaps, $sess);
+$export = new ExportController($events, $snaps, $sess, $audit);
$actionsCtl = new ActionsController($actions, $events, $sess);
$adminCtl = new AdminConfigController($thr, $alerts, $devices, $audit, $sess);
$suppressCtl = new SuppressionController($suppressions, $events, $devices, $audit, $sess);
@@ -132,6 +134,7 @@ $r->add('GET', '/api/devices', fn(Request $q) => $deviceStatus->json($q, $now));
$r->add('GET', '/events/{id}', fn(Request $q, array $p) => $detail->show($q, $p));
$r->add('POST', '/events/{id}/ack', fn(Request $q, array $p) => $detail->acknowledge($q, $p));
$r->add('POST', '/events/{id}/false-positive', fn(Request $q, array $p) => $detail->falsePositive($q, $p));
+$r->add('POST', '/events/{id}/hold', fn(Request $q, array $p) => $detail->hold($q, $p));
$r->add('GET', '/api/zone-presets', fn(Request $q) => $zoneCtl->presets($q));
$r->add('GET', '/api/zones/{camera}', fn(Request $q, array $p) => $zoneCtl->list($q, $p));
$r->add('GET', '/api/heatmap/{camera}', fn(Request $q, array $p) => $heatmap->json($q, $p));
diff --git a/server/schema.sql b/server/schema.sql
index f074e48..84ea29d 100644
--- a/server/schema.sql
+++ b/server/schema.sql
@@ -151,6 +151,11 @@ CREATE TABLE move_events (
acknowledged_at BIGINT UNSIGNED NULL,
false_positive_by VARCHAR(190) NULL, -- operator's 오탐 tag (per-camera FP-rate stats; no auto-tuning)
false_positive_at BIGINT UNSIGNED NULL,
+ -- 증거 보존 보류. 이 시각까지는 보존기간 정리가 이 사건의 스냅샷을 지우지 않는다.
+ -- 중대재해 조사는 몇 주~몇 달인데 스냅샷 기본 보존은 7일이라, 보류가 없으면 "사고 후
+ -- 원인 분석"이라는 목적 자체가 8일째에 사라진다. 자동 연장이 아니라 명시적 조작으로만
+ -- 걸리고(자동이면 결국 전량 보존이 되어 프라이버시 약속이 무너진다) 감사에 남는다.
+ hold_until BIGINT UNSIGNED NULL,
received_at BIGINT UNSIGNED NOT NULL, -- server receipt time (PHP time()), for clock-skew flagging (design doc §8)
PRIMARY KEY (event_id),
-- Idempotency: a UNIQUE key on event_uuid makes a re-sent event (POST succeeded server-side
@@ -160,6 +165,7 @@ CREATE TABLE move_events (
KEY ix_events_occurred_at (occurred_at),
KEY ix_events_severity_occurred_at (severity, occurred_at),
KEY ix_events_site_occurred_at (site, occurred_at), -- site-scoped dashboard list (P3-2b)
+ KEY ix_events_hold (hold_until), -- 보존 정리가 보류된 건만 빠르게 걸러낸다
KEY ix_events_device (device_id),
KEY ix_events_camera (camera_id, event_id), -- zone editor: newest snapshot per camera
CONSTRAINT chk_events_type CHECK (event_type IN (
@@ -414,6 +420,23 @@ CREATE TABLE move_device_metrics (
KEY ix_device_metrics_device_ts (device_id, ts)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+-- 감시자의 생존 기록.
+--
+-- 죽은 노드는 스스로 신고할 수 없어서 크론(tools/dispatch_alerts.php)이 하트비트의 부재로
+-- 판정한다. 그런데 그 크론이 멈추면 아무도 판정하지 않는다 — 통보·5분 확대·장치 이상 발행이
+-- 전부 그 한 프로세스에 걸려 있어서, 멈춘 순간부터 화면은 조용해지고 그 침묵은 "사고 없음"과
+-- 구별되지 않는다. 감시자의 생존은 감시자가 아닌 쪽이 읽어야 하므로, 크론이 매 실행마다 자기
+-- 시각을 여기 남기고 대시보드(/api/devices)가 그 나이를 본다.
+--
+-- 범용 키-값이지만 용도를 넓히지 말 것: 설정은 config.php, 임계값은 move_thresholds 가 있다.
+-- 여기 들어갈 것은 "프로세스가 언제 살아 있었는가" 뿐이다.
+CREATE TABLE move_app_state (
+ k VARCHAR(64) NOT NULL,
+ v VARCHAR(255) NOT NULL,
+ updated_at BIGINT UNSIGNED NOT NULL,
+ PRIMARY KEY (k)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
-- ===========================================================================
-- UPGRADING AN EXISTING DATABASE (fresh installs already have everything above):
-- do NOT copy the statements below by hand — they are SQL comments and will not
diff --git a/server/src/Dashboard/DeviceStatusController.php b/server/src/Dashboard/DeviceStatusController.php
index e0e4178..af5907b 100644
--- a/server/src/Dashboard/DeviceStatusController.php
+++ b/server/src/Dashboard/DeviceStatusController.php
@@ -6,7 +6,7 @@ use App\Config;
use App\Http\{Request, Response};
use App\Auth\SessionStore;
use App\Security\SiteScope;
-use App\Repository\{DeviceRepository, DeviceMetricRepository};
+use App\Repository\{DeviceRepository, DeviceMetricRepository, AppStateRepository};
/**
* GET /api/devices — device/camera health for the dashboard's status panel. The edge already
@@ -27,13 +27,22 @@ final class DeviceStatusController
// yet a genuinely dead camera still surfaces within ~2 min (a couple of dashboard polls).
private const OFFLINE_AFTER_S = 120;
+ /**
+ * 통보 디스패처(크론)가 이 시간 넘게 자기 실행을 남기지 않았으면 멈춘 것으로 본다.
+ * 크론은 1분 주기이므로 5배 여유 — 공유호스팅에서 한두 주기가 밀리는 것과 진짜로
+ * 죽은 것을 가른다. 이 판정이 필요한 이유는 크론이 죽으면 통보·5분 확대·장치 이상
+ * 발행이 한꺼번에 조용히 멈추고, 그 침묵이 "사고 없음"과 똑같이 보이기 때문이다.
+ */
+ private const DISPATCHER_STALE_AFTER_S = 300;
+
/** Series window: 24h of 30s beats is ~2,880 points, which draws fine and downloads small. */
private const SERIES_WINDOW_S = 86400;
private const SERIES_LIMIT = 3000;
public function __construct(
private DeviceRepository $devices, private SessionStore $sess,
- private ?DeviceMetricRepository $metrics = null, private ?Config $cfg = null
+ private ?DeviceMetricRepository $metrics = null, private ?Config $cfg = null,
+ private ?AppStateRepository $state = null
) {}
public function json(Request $req, int $now): Response
@@ -63,7 +72,11 @@ final class DeviceStatusController
];
}, $this->devices->listStatus($site));
- return Response::json(['devices' => $devices, 'storage' => $this->storage()], 200);
+ return Response::json([
+ 'devices' => $devices,
+ 'storage' => $this->storage(),
+ 'dispatcher' => $this->dispatcher($now),
+ ], 200);
}
/**
@@ -122,4 +135,35 @@ final class DeviceStatusController
'low' => $freeMb < $minFreeMb,
];
}
+
+ /**
+ * 통보 디스패처의 생존.
+ *
+ * 이 판정만 브라우저가 읽어야 하는 이유: 크론이 죽으면 크론이 발행하는 어떤 경고도
+ * 나오지 않는다. device_fault 는 하트비트의 부재로 노드를 판정하지만, 그 판정을 하는
+ * 주체가 바로 이 크론이다. 그래서 크론의 부재는 크론 밖에서만 보인다.
+ *
+ * never = 아직 한 번도 안 돌았다(설치 직후 크론 미등록). 이걸 stale 과 구별하는 이유는
+ * 조치가 다르기 때문이다 — 전자는 등록하는 것이고 후자는 왜 멈췄는지 보는 것이다.
+ *
+ * @return array{last_run_at:int|null, seconds_since:int|null, stale:bool, stale_after_s:int}
+ */
+ private function dispatcher(int $now): array
+ {
+ $row = null;
+ try {
+ $row = $this->state?->get('dispatch_alerts.last_run');
+ } catch (\Throwable) {
+ // 표가 아직 없는(마이그레이션 전) 배포에서 상태 패널 전체가 죽지 않게 한다.
+ // 그 경우 "한 번도 안 돌았다"로 보이고, 그것이 실제로 맞는 상태다.
+ }
+ $last = $row !== null ? (int)$row['updated_at'] : null;
+ $since = $last !== null ? max(0, $now - $last) : null;
+ return [
+ 'last_run_at' => $last,
+ 'seconds_since' => $since,
+ 'stale' => $since === null || $since > self::DISPATCHER_STALE_AFTER_S,
+ 'stale_after_s' => self::DISPATCHER_STALE_AFTER_S,
+ ];
+ }
}
diff --git a/server/src/Dashboard/EventDetailController.php b/server/src/Dashboard/EventDetailController.php
index dce8805..275449e 100644
--- a/server/src/Dashboard/EventDetailController.php
+++ b/server/src/Dashboard/EventDetailController.php
@@ -9,6 +9,10 @@ use App\Repository\{EventRepository, SnapshotRepository, AuditRepository};
final class EventDetailController
{
+ /** 1년. 이보다 긴 보존이 필요하면 사건을 따로 내보내는 것이 맞지, 사진을 계속
+ * 들고 있는 것이 맞지 않다. 무기한 보류는 프라이버시 약속의 예외가 아니라 폐기다. */
+ private const MAX_HOLD_DAYS = 365;
+
public function __construct(
private EventRepository $events, private SnapshotRepository $snaps,
private AuditRepository $audit, private SessionStore $sess
@@ -68,4 +72,44 @@ final class EventDetailController
$this->audit->append($user, 'false_positive', "event:$id", [], []);
return Response::json(['ok' => true], 200);
}
+
+ /**
+ * POST /events/{id}/hold — 증거 보존 보류 (본문: {"days": 1..365} 또는 {"days": 0} 해제).
+ *
+ * 스냅샷은 기본 7일 뒤 지워진다. 그 규칙이 없으면 작업자 사진이 영원히 쌓이므로 규칙
+ * 자체는 옳은데, 중대재해 조사는 몇 주에서 몇 달이라 조사가 시작되기도 전에 사진 증거가
+ * 사라졌다. "사고 후 원인 분석" 이라는 우리가 내건 목적이 8일째에 스스로 지워지는 상태.
+ *
+ * 자동 연장이 아닌 이유는 확인(acknowledge)에 얹으면 사실상 전량 보존이 되어 프라이버시
+ * 약속이 무너지기 때문이고, admin(manage_compliance)으로 막은 이유는 보존기간 연장이
+ * 운영 편의가 아니라 규정 판단이기 때문이다. 누가 언제 얼마나 늘렸는지가 감사에 남는다 —
+ * 노조에 "전부 지웁니다, 단 사고 확정 건은 조사 종료까지, 기록과 함께" 라고 말할 수 있는
+ * 근거가 이 한 행이다.
+ */
+ public function hold(Request $req, array $params): Response
+ {
+ if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
+ if (!Rbac::can((string)$this->sess->get('role'), 'manage_compliance')) {
+ return Response::json(['error' => 'forbidden'], 403);
+ }
+ if (!Csrf::check((string)$this->sess->get('csrf'), (string)$req->header('X-Csrf-Token'))) {
+ return Response::json(['error' => 'csrf'], 403);
+ }
+ $id = (int)($params['id'] ?? 0);
+ $event = $this->events->find($id);
+ if ($event === null || $this->outOfScope($event)) return Response::json(['error' => 'not_found'], 404);
+
+ $raw = $req->json()['days'] ?? null;
+ if (!is_numeric($raw)) return Response::json(['error' => 'bad_days'], 400);
+ $days = (int)$raw;
+ if ($days < 0 || $days > self::MAX_HOLD_DAYS) return Response::json(['error' => 'bad_days'], 400);
+
+ $until = $days === 0 ? null : time() + $days * 86400;
+ if (!$this->events->setHold($id, $until)) return Response::json(['error' => 'not_found'], 404);
+
+ $user = (string)$this->sess->get('username');
+ $this->audit->append($user, $until === null ? 'evidence_hold_release' : 'evidence_hold',
+ "event:$id", ['hold_until' => $event['hold_until'] ?? null], ['hold_until' => $until]);
+ return Response::json(['ok' => true, 'hold_until' => $until], 200);
+ }
}
diff --git a/server/src/Dashboard/ExportController.php b/server/src/Dashboard/ExportController.php
index 701a8c5..c19cc52 100644
--- a/server/src/Dashboard/ExportController.php
+++ b/server/src/Dashboard/ExportController.php
@@ -4,7 +4,7 @@ namespace App\Dashboard;
use App\Http\{Request, Response};
use App\Auth\{SessionStore, Rbac};
-use App\Repository\{EventRepository, SnapshotRepository};
+use App\Repository\{EventRepository, SnapshotRepository, AuditRepository};
/**
* GET /api/export/labels?days=N -> JSONL download.
@@ -29,7 +29,7 @@ final class ExportController
public function __construct(
private EventRepository $events, private SnapshotRepository $snaps,
- private SessionStore $sess
+ private SessionStore $sess, private ?AuditRepository $audit = null
) {}
public function labels(Request $req): Response
@@ -69,6 +69,14 @@ final class ExportController
'snapshots' => $uuids[(int)$r['event_id']] ?? [],
], JSON_UNESCAPED_UNICODE);
}
+ // 대량 반출은 남긴다. 이 한 번의 호출이 최대 2만 건의 사건과 그에 딸린 스냅샷 uuid
+ // 전부를 파일 하나로 내보내는데, 기록이 없으면 "지난달에 우리 현장 데이터가 통째로
+ // 나간 적 있습니까"에 답할 수 없다. 열람 감사(snapshot_view)와 짝이며, 이쪽은 드물게
+ // 일어나므로 무조건 한 행씩 남긴다. 반출 사실을 남기는 것이지 반출을 막는 장치가
+ // 아니므로, 기록이 실패해도 파일은 내보낸다.
+ $this->audit?->append((string)$this->sess->get('username'), 'label_export',
+ 'export:labels', [], ['days' => $days, 'rows' => count($lines), 'site' => $site]);
+
$body = implode("\n", $lines) . ($lines ? "\n" : '');
return Response::bytes($body, 'application/x-ndjson; charset=utf-8', 200, [
'Content-Disposition' => 'attachment; filename="labels-' . date('Ymd') . '-' . $days . 'd.jsonl"',
diff --git a/server/src/Dashboard/SnapshotProxy.php b/server/src/Dashboard/SnapshotProxy.php
index ae4bbc4..46731c1 100644
--- a/server/src/Dashboard/SnapshotProxy.php
+++ b/server/src/Dashboard/SnapshotProxy.php
@@ -5,12 +5,19 @@ namespace App\Dashboard;
use App\Http\{Request, Response};
use App\Auth\SessionStore;
use App\Security\SiteScope;
-use App\Repository\{SnapshotRepository, EventRepository};
+use App\Repository\{SnapshotRepository, EventRepository, AuditRepository};
final class SnapshotProxy
{
+ /**
+ * 세션당 기억할 열람 uuid 수. 넘으면 오래된 것부터 버리고, 버려진 uuid 를 다시 보면
+ * 감사에 한 줄 더 남는다 — 넘치면 **덜 기록**이 아니라 **더 기록**하는 방향으로 틀어진다.
+ */
+ private const VIEWED_MEMO_CAP = 500;
+
public function __construct(
- private SnapshotRepository $snaps, private EventRepository $events, private SessionStore $sess
+ private SnapshotRepository $snaps, private EventRepository $events, private SessionStore $sess,
+ private ?AuditRepository $audit = null
) {}
public function serve(Request $req, array $params): Response
@@ -30,6 +37,7 @@ final class SnapshotProxy
return Response::json(['error' => 'not_found'], 404);
}
}
+ $this->recordView($uuid);
// A snapshot is immutable per uuid, so let the browser cache it. Without this the event
// list — which rebuilds every on each 5s poll — re-downloads every
// visible full-size JPEG (a PHP call + DB reads + file read) every 5 seconds per open
@@ -38,4 +46,42 @@ final class SnapshotProxy
return Response::bytes((string)file_get_contents($path), 'image/jpeg', 200,
['Cache-Control' => 'private, max-age=86400, immutable']);
}
+
+ /**
+ * 작업자 사진을 누가 언제 봤는지 남긴다.
+ *
+ * 이 시스템은 쓰기를 전부 감사한다 — 임계값 한 번 바꾼 것까지 해시 체인에 들어간다.
+ * 그런데 읽기는 하나도 남지 않았다. 관리자가 조합원 사진 200장을 열어봐도 흔적이 없고,
+ * 교섭에서 "안 봤습니다"는 증명이 아니라 주장이다. 감시 시스템과 안전 시스템을 가르는
+ * 선이 정확히 여기라서, 나머지 프라이버시 설계(RAM 링버퍼·전송 전 블러·라이브뷰 부재)가
+ * 전부 이 한 줄이 없어서 검증 불가능한 주장에 머물러 있었다.
+ *
+ * 세션 안에서 같은 uuid 는 한 번만 기록한다. 목록 화면은 5초마다 를 다시 그리고
+ * (브라우저 캐시가 대부분 막지만 시크릿 창·캐시 해제는 그대로 들어온다), 감사 추가는
+ * 체인 꼬리에 행 잠금을 잡는 작업이라 장당 한 행씩 쓰면 공유호스팅에서 직렬화된다.
+ * "이 로그인 세션에서 이 사진을 봤다"는 노조가 묻는 질문에 답하기 충분하다.
+ *
+ * 기록 실패가 사진 열람을 막지는 않는다 — 감사가 안전 기능을 죽이면 안 된다. 다만 그
+ * 경우 메모에 넣지 않아서 다음 요청이 다시 시도한다.
+ */
+ private function recordView(string $uuid): void
+ {
+ if ($this->audit === null) return;
+ $seen = $this->sess->get('snap_viewed');
+ $seen = is_array($seen) ? $seen : [];
+ if (in_array($uuid, $seen, true)) return;
+
+ try {
+ $this->audit->append((string)$this->sess->get('username'), 'snapshot_view',
+ "snapshot:$uuid", [], []);
+ } catch (\Throwable) {
+ return; // 메모에 안 넣는다 → 다음 요청에서 재시도
+ }
+
+ $seen[] = $uuid;
+ if (count($seen) > self::VIEWED_MEMO_CAP) {
+ $seen = array_slice($seen, -self::VIEWED_MEMO_CAP);
+ }
+ $this->sess->set('snap_viewed', $seen);
+ }
}
diff --git a/server/src/Repository/AppStateRepository.php b/server/src/Repository/AppStateRepository.php
new file mode 100644
index 0000000..554dfc4
--- /dev/null
+++ b/server/src/Repository/AppStateRepository.php
@@ -0,0 +1,18 @@
+pdo->prepare('SELECT v, updated_at FROM move_app_state WHERE k = ?');
+ $st->execute([$key]);
+ $row = $st->fetch();
+ if ($row === false) return null;
+ return ['value' => (string)$row['v'], 'updated_at' => (int)$row['updated_at']];
+ }
+
+ public function set(string $key, string $value, int $now): void
+ {
+ // upsert 문법이 엔진마다 다르다. sqlite 통합 테스트가 납품되는 실제 SQL 을 돌리므로
+ // 둘 다 맞아야 한다 — MySQL 은 ON DUPLICATE KEY, sqlite 는 ON CONFLICT.
+ $mysql = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) !== 'sqlite';
+ $sql = $mysql
+ ? 'INSERT INTO move_app_state (k, v, updated_at) VALUES (?, ?, ?)
+ ON DUPLICATE KEY UPDATE v = VALUES(v), updated_at = VALUES(updated_at)'
+ : 'INSERT INTO move_app_state (k, v, updated_at) VALUES (?, ?, ?)
+ ON CONFLICT(k) DO UPDATE SET v = excluded.v, updated_at = excluded.updated_at';
+ $this->pdo->prepare($sql)->execute([$key, $value, $now]);
+ }
+}
diff --git a/server/src/Repository/Pdo/PdoEventRepository.php b/server/src/Repository/Pdo/PdoEventRepository.php
index b73981d..7fc7fff 100644
--- a/server/src/Repository/Pdo/PdoEventRepository.php
+++ b/server/src/Repository/Pdo/PdoEventRepository.php
@@ -126,6 +126,21 @@ final class PdoEventRepository implements EventRepository
return $row === false ? null : self::mapRow($row);
}
+ public function setHold(int $id, ?int $until): bool
+ {
+ // acknowledge() 와 같은 이유로 존재 확인을 따로 한다 — MySQL PDO 는 기본적으로
+ // '변경된 행'을 세므로, 같은 값으로 다시 보류하면 rowCount() 가 0 이 된다.
+ $exists = $this->pdo->prepare('SELECT 1 FROM move_events WHERE event_id = ? LIMIT 1');
+ $exists->execute([$id]);
+ if ($exists->fetchColumn() === false) return false;
+
+ $st = $this->pdo->prepare('UPDATE move_events SET hold_until = ? WHERE event_id = ?');
+ $st->bindValue(1, $until, $until === null ? PDO::PARAM_NULL : PDO::PARAM_INT);
+ $st->bindValue(2, $id, PDO::PARAM_INT);
+ $st->execute();
+ return true;
+ }
+
public function acknowledge(int $id, string $user): bool
{
// Existence is checked separately (rather than trusting UPDATE's
diff --git a/server/src/Repository/Pdo/PdoSnapshotRepository.php b/server/src/Repository/Pdo/PdoSnapshotRepository.php
index 3cbcc04..5dcc213 100644
--- a/server/src/Repository/Pdo/PdoSnapshotRepository.php
+++ b/server/src/Repository/Pdo/PdoSnapshotRepository.php
@@ -121,16 +121,28 @@ final class PdoSnapshotRepository implements SnapshotRepository
return $id === false ? null : (int)$id;
}
- public function purgeOlderThan(int $cutoff, int $limit = 500): int
+ public function purgeOlderThan(int $cutoff, int $limit = 500, ?int $now = null): int
{
+ $now ??= time();
// Bounded batch: a single request must not stall unlinking thousands of files on a
// shared host. Repeated ingests drain the backlog. Files first, then rows — an orphan
// row (file gone, row left) is harmless; an orphan file (row gone, file left) is the
// PIPA leak we are closing, so never delete the row before its file.
+ // 보존 보류(legal hold)된 사건의 스냅샷은 나이가 지나도 남긴다. 보류가 없던 동안
+ // 이 삭제는 나이 하나만 봤고, 그래서 중대재해 조사가 시작되기도 전에(스냅샷 기본
+ // 7일) 사진 증거가 사라졌다 — 우리가 내세운 목적이 스스로 지워지는 상태였다.
+ // LEFT JOIN 인 이유: 이벤트 행이 먼저 정리된 고아 스냅샷은 보류가 아니라 잔여물이고,
+ // 그건 지우는 쪽이 맞다(hold_until 이 NULL 로 나와 자연히 대상이 된다).
$sel = $this->pdo->prepare(
- 'SELECT snapshot_id, file_uuid FROM move_event_snapshots WHERE created_at < ? LIMIT ?');
+ 'SELECT s.snapshot_id, s.file_uuid
+ FROM move_event_snapshots s
+ LEFT JOIN move_events e ON e.event_id = s.event_id
+ WHERE s.created_at < ?
+ AND (e.hold_until IS NULL OR e.hold_until <= ?)
+ LIMIT ?');
$sel->bindValue(1, $cutoff, PDO::PARAM_INT);
- $sel->bindValue(2, $limit, PDO::PARAM_INT);
+ $sel->bindValue(2, $now, PDO::PARAM_INT);
+ $sel->bindValue(3, $limit, PDO::PARAM_INT);
$sel->execute();
$rows = $sel->fetchAll();
if (!$rows) {
diff --git a/server/src/Repository/SnapshotRepository.php b/server/src/Repository/SnapshotRepository.php
index a044929..4134008 100644
--- a/server/src/Repository/SnapshotRepository.php
+++ b/server/src/Repository/SnapshotRepository.php
@@ -23,7 +23,9 @@ interface SnapshotRepository
public function latestForCamera(string $cameraId): ?array;
/** Delete snapshot files + rows created before $cutoff (unix ts). PIPA retention:
- * worker images must not be kept forever. Returns the number purged. */
+ * worker images must not be kept forever. Returns the number purged.
+ * 증거 보존 보류(move_events.hold_until)가 걸린 사건의 스냅샷은 나이와 무관하게 남긴다 —
+ * 조사가 끝나기 전에 증거가 사라지면 이 제품의 목적 자체가 사라진다. */
public function purgeOlderThan(int $cutoff): int;
/** ALL snapshot uuids per event, as [event_id => [file_uuid, ...]] in stored order —
diff --git a/server/src/View/event_detail.php b/server/src/View/event_detail.php
index d90a6e2..e22b3de 100644
--- a/server/src/View/event_detail.php
+++ b/server/src/View/event_detail.php
@@ -12,6 +12,7 @@ render_header('이벤트 상세');
+
diff --git a/server/src/View/events.php b/server/src/View/events.php
index 0c3a597..11837a0 100644
--- a/server/src/View/events.php
+++ b/server/src/View/events.php
@@ -108,6 +108,7 @@ render_header('이벤트');
+
diff --git a/server/src/View/layout.php b/server/src/View/layout.php
index 014fbcc..bfeb771 100644
--- a/server/src/View/layout.php
+++ b/server/src/View/layout.php
@@ -26,6 +26,10 @@ if (!function_exists('view_url')) {
if (!function_exists('render_header')) {
function render_header(string $title): void {
$username = is_string($_SESSION['username'] ?? null) ? $_SESSION['username'] : null;
+ // 표시용일 뿐 권한 판정이 아니다. 보존 보류 버튼을 admin 에게만 보여주려고 쓰는데,
+ // 실제 차단은 EventDetailController 의 manage_compliance 검사가 한다 — 여기를
+ // 조작해도 서버가 403 을 낸다.
+ $role = is_string($_SESSION['role'] ?? null) ? $_SESSION['role'] : null;
// Which primary nav tab is active is derived from the page title so no view
// has to pass an extra argument. Login/detail titles match neither -> no tab lit.
// 페이지 이름은 nav 라벨 ·
· 세 곳이 같은 문자열을 쓴다. 예전에는
@@ -51,6 +55,7 @@ if (!function_exists('render_header')) {
diff --git a/server/tests/DispatcherLivenessTest.php b/server/tests/DispatcherLivenessTest.php
new file mode 100644
index 0000000..5d72b2d
--- /dev/null
+++ b/server/tests/DispatcherLivenessTest.php
@@ -0,0 +1,167 @@
+lastRun === null) return null;
+ return ['value' => (string)$this->lastRun, 'updated_at' => $this->lastRun];
+ }
+ public function set(string $key, string $value, int $now): void {}
+ };
+ }
+
+ private function dispatcherBlock(?AppStateRepository $state): array
+ {
+ $sess = new ArraySessionStore();
+ $sess->set('user_id', 1);
+ $sess->set('site_scope', null);
+ $ctl = new DeviceStatusController(new InMemoryDeviceRepository(), $sess, null, null, $state);
+ $res = $ctl->json(new Request('GET', '/api/devices', [], '', []), 10_000);
+ return json_decode($res->body(), true)['dispatcher'];
+ }
+
+ public function test_a_recent_run_reads_as_alive(): void
+ {
+ $d = $this->dispatcherBlock($this->state(10_000 - 30));
+ $this->assertFalse($d['stale']);
+ $this->assertSame(30, $d['seconds_since']);
+ }
+
+ public function test_a_stopped_cron_is_visible(): void
+ {
+ // 크론이 5분 넘게 안 돌았다 = 통보가 통째로 멈춘 상태. 화면이 조용한 것이
+ // "사고 없음"이라는 뜻이 아니게 되는 지점이고, 그걸 사람이 알아야 한다.
+ $d = $this->dispatcherBlock($this->state(10_000 - 600));
+ $this->assertTrue($d['stale']);
+ $this->assertSame(600, $d['seconds_since']);
+ }
+
+ public function test_never_run_is_distinguishable_from_stopped(): void
+ {
+ // 조치가 다르다 — 전자는 크론을 등록하는 것이고 후자는 왜 멈췄는지 보는 것이다.
+ $d = $this->dispatcherBlock($this->state(null));
+ $this->assertTrue($d['stale']);
+ $this->assertNull($d['last_run_at']);
+ $this->assertNull($d['seconds_since']);
+ }
+
+ public function test_a_missing_state_table_does_not_break_the_status_panel(): void
+ {
+ // 마이그레이션 전 배포에서 상태 패널 전체가 죽으면, 이 기능을 넣은 대가로 기존
+ // 장치 감시를 잃는다. 그 경우 "한 번도 안 돌았다"로 보이고, 그게 실제로 맞다.
+ $throwing = new class implements AppStateRepository {
+ public function get(string $key): ?array { throw new \RuntimeException('no such table'); }
+ public function set(string $key, string $value, int $now): void {}
+ };
+ $d = $this->dispatcherBlock($throwing);
+ $this->assertTrue($d['stale']);
+ $this->assertNull($d['last_run_at']);
+ }
+
+ public function test_the_panel_still_works_without_the_repository(): void
+ {
+ $d = $this->dispatcherBlock(null);
+ $this->assertTrue($d['stale']);
+ }
+
+ // --- 증거 보존 보류 --------------------------------------------------------------
+
+ /** @return array{0:EventDetailController,1:InMemoryEventRepository,2:InMemoryAuditRepository,3:ArraySessionStore,4:int} */
+ private function detail(string $role): array
+ {
+ $events = new InMemoryEventRepository();
+ $id = $events->insert(['device_id' => 'pi-01', 'camera_id' => 'cam-01',
+ 'event_type' => 'fall', 'severity' => 'high', 'occurred_at' => 1000.0, 'site' => null]);
+ $audit = new InMemoryAuditRepository();
+ $sess = new ArraySessionStore();
+ $sess->set('user_id', 1); $sess->set('username', '관리자');
+ $sess->set('role', $role); $sess->set('site_scope', null);
+ $sess->set('csrf', 'tok');
+ return [new EventDetailController($events, new InMemorySnapshotRepository(), $audit, $sess),
+ $events, $audit, $sess, $id];
+ }
+
+ private function holdReq(mixed $days): Request
+ {
+ return new Request('POST', '/events/1/hold', ['X-Csrf-Token' => 'tok'],
+ json_encode(['days' => $days]), []);
+ }
+
+ public function test_an_admin_can_hold_evidence_and_it_is_recorded(): void
+ {
+ [$ctl, $events, $audit, , $id] = $this->detail('admin');
+
+ $res = $ctl->hold($this->holdReq(90), ['id' => $id]);
+
+ $this->assertSame(200, $res->status());
+ $this->assertNotNull($events->rows[$id]['hold_until']);
+ $this->assertSame('evidence_hold', $audit->log[0]['act'],
+ '보존기간 연장이 기록되지 않으면, 노조에 "사고 건만 기록과 함께 남긴다"고 말할 근거가 없습니다.');
+ }
+
+ public function test_releasing_a_hold_is_recorded_separately(): void
+ {
+ [$ctl, $events, $audit, , $id] = $this->detail('admin');
+ $ctl->hold($this->holdReq(90), ['id' => $id]);
+ $res = $ctl->hold($this->holdReq(0), ['id' => $id]);
+
+ $this->assertSame(200, $res->status());
+ $this->assertNull($events->rows[$id]['hold_until']);
+ $this->assertSame('evidence_hold_release', $audit->log[1]['act']);
+ }
+
+ public function test_an_operator_cannot_extend_retention(): void
+ {
+ // 보존기간 연장은 운영 편의가 아니라 규정 판단이다.
+ [$ctl, $events, $audit, , $id] = $this->detail('operator');
+
+ $this->assertSame(403, $ctl->hold($this->holdReq(90), ['id' => $id])->status());
+ $this->assertNull($events->rows[$id]['hold_until'] ?? null);
+ $this->assertSame([], $audit->log);
+ }
+
+ public function test_an_unbounded_hold_is_refused(): void
+ {
+ // 무기한 보류는 프라이버시 약속의 예외가 아니라 폐기다.
+ [$ctl, $events, , , $id] = $this->detail('admin');
+
+ foreach ([400, -1, 'forever', null] as $bad) {
+ $this->assertSame(400, $ctl->hold($this->holdReq($bad), ['id' => $id])->status(),
+ 'days=' . var_export($bad, true) . ' 를 통과시켰습니다');
+ }
+ $this->assertNull($events->rows[$id]['hold_until'] ?? null);
+ }
+
+ public function test_a_hold_without_csrf_is_refused(): void
+ {
+ [$ctl, , $audit, , $id] = $this->detail('admin');
+ $req = new Request('POST', '/events/1/hold', [], json_encode(['days' => 90]), []);
+
+ $this->assertSame(403, $ctl->hold($req, ['id' => $id])->status());
+ $this->assertSame([], $audit->log);
+ }
+}
diff --git a/server/tests/EvidenceHoldTest.php b/server/tests/EvidenceHoldTest.php
new file mode 100644
index 0000000..8ca99e0
--- /dev/null
+++ b/server/tests/EvidenceHoldTest.php
@@ -0,0 +1,184 @@
+markTestSkipped('pdo_sqlite not available on this host');
+ }
+ $this->pdo = new PDO('sqlite::memory:', null, null, [
+ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
+ PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
+ ]);
+ $this->pdo->exec(<<<'SQL'
+ CREATE TABLE move_events (
+ event_id INTEGER PRIMARY KEY AUTOINCREMENT,
+ device_id TEXT NOT NULL,
+ camera_id TEXT NOT NULL,
+ event_type TEXT NOT NULL,
+ severity TEXT NOT NULL,
+ occurred_at REAL NOT NULL,
+ duration_s REAL NOT NULL DEFAULT 0,
+ hold_until INTEGER,
+ received_at INTEGER NOT NULL
+ );
+ CREATE TABLE move_event_snapshots (
+ snapshot_id INTEGER PRIMARY KEY AUTOINCREMENT,
+ event_id INTEGER NOT NULL,
+ file_uuid TEXT NOT NULL UNIQUE,
+ content_hash TEXT NOT NULL,
+ created_at INTEGER NOT NULL
+ );
+ CREATE TABLE move_app_state (
+ k TEXT PRIMARY KEY,
+ v TEXT NOT NULL,
+ updated_at INTEGER NOT NULL
+ );
+ SQL);
+ $this->dir = sys_get_temp_dir() . '/move-hold-' . bin2hex(random_bytes(6));
+ mkdir($this->dir);
+ }
+
+ protected function tearDown(): void
+ {
+ if (!isset($this->dir) || !is_dir($this->dir)) return;
+ foreach (glob($this->dir . '/*') ?: [] as $f) @unlink($f);
+ @rmdir($this->dir);
+ }
+
+ /** 한 사건 + 그 사건의 스냅샷 1장. 반환값은 [event_id, file_uuid]. */
+ private function seed(int $createdAt, ?int $holdUntil = null): array
+ {
+ $st = $this->pdo->prepare(
+ 'INSERT INTO move_events (device_id, camera_id, event_type, severity, occurred_at,
+ hold_until, received_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?)');
+ $st->execute(['pi-01', 'cam-01', 'fall', 'high', (float)$createdAt, $holdUntil, $createdAt]);
+ $eventId = (int)$this->pdo->lastInsertId();
+
+ $uuid = bin2hex(random_bytes(16));
+ file_put_contents($this->dir . '/' . $uuid . '.jpg', 'jpeg-bytes');
+ $this->pdo->prepare(
+ 'INSERT INTO move_event_snapshots (event_id, file_uuid, content_hash, created_at)
+ VALUES (?, ?, ?, ?)')->execute([$eventId, $uuid, str_repeat('a', 64), $createdAt]);
+ return [$eventId, $uuid];
+ }
+
+ private function snaps(): PdoSnapshotRepository
+ {
+ return new PdoSnapshotRepository($this->pdo, $this->dir);
+ }
+
+ public function test_an_old_snapshot_is_purged_when_nothing_holds_it(): void
+ {
+ [, $uuid] = $this->seed(1_000);
+ $purged = $this->snaps()->purgeOlderThan(2_000, 500, 3_000);
+
+ $this->assertSame(1, $purged);
+ $this->assertFileDoesNotExist($this->dir . '/' . $uuid . '.jpg');
+ }
+
+ public function test_a_held_snapshot_survives_its_retention_window(): void
+ {
+ [, $uuid] = $this->seed(1_000, holdUntil: 9_000);
+ $purged = $this->snaps()->purgeOlderThan(2_000, 500, 3_000);
+
+ $this->assertSame(0, $purged, '조사 중인 사건의 사진이 보존기간 때문에 지워졌습니다.');
+ $this->assertFileExists($this->dir . '/' . $uuid . '.jpg');
+ }
+
+ public function test_the_hold_stops_protecting_once_it_expires(): void
+ {
+ // 무기한 보류는 프라이버시 약속의 예외가 아니라 폐기다. 기간이 지나면 다시 대상이 된다.
+ [, $uuid] = $this->seed(1_000, holdUntil: 2_500);
+ $purged = $this->snaps()->purgeOlderThan(2_000, 500, 3_000);
+
+ $this->assertSame(1, $purged);
+ $this->assertFileDoesNotExist($this->dir . '/' . $uuid . '.jpg');
+ }
+
+ public function test_one_hold_does_not_shield_other_events(): void
+ {
+ // JOIN 조건이 틀리면(예: 보류 행이 하나라도 있으면 전부 남김) 프라이버시 기본값이
+ // 조용히 "영구 보존"으로 뒤집힌다. 한 건만 남고 나머지는 지워져야 한다.
+ [, $held] = $this->seed(1_000, holdUntil: 9_000);
+ [, $a] = $this->seed(1_000);
+ [, $b] = $this->seed(1_000);
+
+ $purged = $this->snaps()->purgeOlderThan(2_000, 500, 3_000);
+
+ $this->assertSame(2, $purged);
+ $this->assertFileExists($this->dir . '/' . $held . '.jpg');
+ $this->assertFileDoesNotExist($this->dir . '/' . $a . '.jpg');
+ $this->assertFileDoesNotExist($this->dir . '/' . $b . '.jpg');
+ }
+
+ public function test_an_orphan_snapshot_is_still_purged(): void
+ {
+ // 이벤트 행이 먼저 정리된 스냅샷은 보류가 아니라 잔여물이다. LEFT JOIN 이 NULL 을
+ // 내므로 대상이 되어야 한다 — INNER JOIN 으로 쓰면 이게 영원히 안 지워지고,
+ // 작업자 사진이 보존기간을 넘겨 디스크에 남는 PIPA 누출이 된다.
+ $uuid = bin2hex(random_bytes(16));
+ file_put_contents($this->dir . '/' . $uuid . '.jpg', 'jpeg-bytes');
+ $this->pdo->prepare(
+ 'INSERT INTO move_event_snapshots (event_id, file_uuid, content_hash, created_at)
+ VALUES (?, ?, ?, ?)')->execute([9999, $uuid, str_repeat('b', 64), 1_000]);
+
+ $this->assertSame(1, $this->snaps()->purgeOlderThan(2_000, 500, 3_000));
+ $this->assertFileDoesNotExist($this->dir . '/' . $uuid . '.jpg');
+ }
+
+ public function test_setting_and_clearing_a_hold_round_trips(): void
+ {
+ [$id] = $this->seed(1_000);
+ $events = new PdoEventRepository($this->pdo);
+
+ $this->assertTrue($events->setHold($id, 9_000));
+ $this->assertSame(0, $this->snaps()->purgeOlderThan(2_000, 500, 3_000));
+
+ $this->assertTrue($events->setHold($id, null));
+ $this->assertSame(1, $this->snaps()->purgeOlderThan(2_000, 500, 3_000));
+ }
+
+ public function test_holding_a_missing_event_reports_failure(): void
+ {
+ $this->assertFalse((new PdoEventRepository($this->pdo))->setHold(4242, 9_000));
+ }
+
+ public function test_app_state_upsert_round_trips(): void
+ {
+ $state = new PdoAppStateRepository($this->pdo);
+ $this->assertNull($state->get('dispatch_alerts.last_run'));
+
+ $state->set('dispatch_alerts.last_run', '1000', 1000);
+ $this->assertSame(['value' => '1000', 'updated_at' => 1000],
+ $state->get('dispatch_alerts.last_run'));
+
+ // 덮어쓰기여야 한다. 행이 쌓이면 대시보드가 첫 행(옛날 시각)을 읽고 크론이 죽었다고
+ // 오판하거나, 그 반대로 영원히 살아있다고 본다.
+ $state->set('dispatch_alerts.last_run', '2000', 2000);
+ $this->assertSame(['value' => '2000', 'updated_at' => 2000],
+ $state->get('dispatch_alerts.last_run'));
+ $this->assertSame(1, (int)$this->pdo->query('SELECT COUNT(*) FROM move_app_state')->fetchColumn());
+ }
+}
diff --git a/server/tests/PdoIntegrationTest.php b/server/tests/PdoIntegrationTest.php
index a0aa3dd..25d8da4 100644
--- a/server/tests/PdoIntegrationTest.php
+++ b/server/tests/PdoIntegrationTest.php
@@ -460,9 +460,16 @@ final class PdoIntegrationTest extends TestCase
acknowledged_at INTEGER,
false_positive_by TEXT,
false_positive_at INTEGER,
+ hold_until INTEGER,
received_at INTEGER NOT NULL
);
+ CREATE TABLE move_app_state (
+ k TEXT PRIMARY KEY,
+ v TEXT NOT NULL,
+ updated_at INTEGER NOT NULL
+ );
+
CREATE TABLE move_zones (
zone_id TEXT PRIMARY KEY,
device_id TEXT NOT NULL,
diff --git a/server/tests/ReadAuditTest.php b/server/tests/ReadAuditTest.php
new file mode 100644
index 0000000..4af35f8
--- /dev/null
+++ b/server/tests/ReadAuditTest.php
@@ -0,0 +1,184 @@
+dir = sys_get_temp_dir() . '/move-readaudit-' . bin2hex(random_bytes(6));
+ mkdir($this->dir);
+ }
+
+ protected function tearDown(): void
+ {
+ foreach (glob($this->dir . '/*') ?: [] as $f) @unlink($f);
+ @rmdir($this->dir);
+ }
+
+ /** @return array{0:SnapshotProxy,1:InMemoryAuditRepository,2:ArraySessionStore,3:string} */
+ private function proxy(?string $siteScope = null): array
+ {
+ $events = new InMemoryEventRepository();
+ $eventId = $events->insert([
+ 'device_id' => 'pi-01', 'camera_id' => 'cam-01', 'event_type' => 'fall',
+ 'severity' => 'high', 'occurred_at' => 1000.0, 'site' => 'A동',
+ ]);
+
+ $uuid = bin2hex(random_bytes(16));
+ file_put_contents($this->dir . '/' . $uuid . '.jpg', 'jpeg-bytes');
+ $snaps = new InMemorySnapshotRepository($this->dir);
+ $snaps->add($eventId, $uuid, str_repeat('a', 64));
+
+ $audit = new InMemoryAuditRepository();
+ $sess = new ArraySessionStore();
+ $sess->set('user_id', 7);
+ $sess->set('username', '관리자');
+ $sess->set('role', 'admin');
+ $sess->set('site_scope', $siteScope);
+
+ return [new SnapshotProxy($snaps, $events, $sess, $audit), $audit, $sess, $uuid];
+ }
+
+ private function req(): Request
+ {
+ return new Request('GET', '/snap', [], '', []);
+ }
+
+ public function test_viewing_a_worker_image_is_recorded(): void
+ {
+ [$proxy, $audit, , $uuid] = $this->proxy();
+
+ $res = $proxy->serve($this->req(), ['uuid' => $uuid]);
+
+ $this->assertSame(200, $res->status());
+ $this->assertCount(1, $audit->log, '사진을 열었는데 감사에 아무것도 남지 않았습니다.');
+ $this->assertSame('snapshot_view', $audit->log[0]['act']);
+ $this->assertSame("snapshot:$uuid", $audit->log[0]['t']);
+ $this->assertSame('관리자', $audit->log[0]['a']);
+ }
+
+ public function test_the_same_image_in_one_session_is_recorded_once(): void
+ {
+ // 목록 화면은 5초마다 를 다시 그린다. 감사 추가는 해시 체인 꼬리에 행 잠금을
+ // 잡는 작업이라, 장당 한 행씩 쓰면 공유호스팅에서 직렬화된다. "이 로그인 세션에서
+ // 이 사진을 봤다"가 노조가 묻는 질문에 답하기 충분하다.
+ [$proxy, $audit, , $uuid] = $this->proxy();
+
+ for ($i = 0; $i < 5; $i++) $proxy->serve($this->req(), ['uuid' => $uuid]);
+
+ $this->assertCount(1, $audit->log);
+ }
+
+ public function test_a_different_image_is_recorded_separately(): void
+ {
+ [$proxy, $audit, , $uuid] = $this->proxy();
+ $proxy->serve($this->req(), ['uuid' => $uuid]);
+
+ // 없는 uuid 는 404 라 기록도 없다 — 기록은 "본 것"이지 "요청한 것"이 아니다.
+ $missing = $proxy->serve($this->req(), ['uuid' => bin2hex(random_bytes(16))]);
+ $this->assertSame(404, $missing->status());
+ $this->assertCount(1, $audit->log);
+ }
+
+ public function test_an_unauthenticated_request_records_nothing(): void
+ {
+ [$proxy, $audit, $sess, $uuid] = $this->proxy();
+ $sess->set('user_id', null);
+
+ $this->assertSame(401, $proxy->serve($this->req(), ['uuid' => $uuid])->status());
+ $this->assertSame([], $audit->log);
+ }
+
+ public function test_a_blocked_cross_site_read_records_nothing(): void
+ {
+ // 거부된 열람은 열람이 아니다. 여기서 기록하면 감사 로그가 "본 사람" 목록이 아니라
+ // "요청한 사람" 목록이 되어, 정작 누가 봤는지를 묻는 데 쓸 수 없게 된다.
+ [$proxy, $audit, , $uuid] = $this->proxy(siteScope: 'B동');
+
+ $this->assertSame(404, $proxy->serve($this->req(), ['uuid' => $uuid])->status());
+ $this->assertSame([], $audit->log);
+ }
+
+ public function test_a_failing_audit_does_not_block_the_image(): void
+ {
+ // 감사가 안전 기능을 죽이면 안 된다 — 사고 조사 중에 DB 가 흔들린다고 사진이 안 열리면
+ // 그건 프라이버시 보호가 아니라 장애다. 대신 세션 메모에 넣지 않아 다음에 재시도한다.
+ $events = new InMemoryEventRepository();
+ $eventId = $events->insert(['device_id' => 'pi-01', 'camera_id' => 'cam-01',
+ 'event_type' => 'fall', 'severity' => 'high', 'occurred_at' => 1000.0, 'site' => null]);
+ $uuid = bin2hex(random_bytes(16));
+ file_put_contents($this->dir . '/' . $uuid . '.jpg', 'jpeg-bytes');
+ $snaps = new InMemorySnapshotRepository($this->dir);
+ $snaps->add($eventId, $uuid, str_repeat('a', 64));
+
+ $audit = new class implements \App\Repository\AuditRepository {
+ public int $calls = 0;
+ public function append(string $a, string $act, string $t, array $b, array $af): void
+ { $this->calls++; throw new \RuntimeException('db down'); }
+ };
+ $sess = new ArraySessionStore();
+ $sess->set('user_id', 7); $sess->set('username', '관리자'); $sess->set('site_scope', null);
+
+ $proxy = new SnapshotProxy($snaps, $events, $sess, $audit);
+ $this->assertSame(200, $proxy->serve($this->req(), ['uuid' => $uuid])->status());
+ $proxy->serve($this->req(), ['uuid' => $uuid]);
+ $this->assertSame(2, $audit->calls, '기록 실패 후 재시도하지 않았습니다 — 조용히 누락됩니다.');
+ }
+
+ public function test_a_bulk_export_is_recorded_with_its_size(): void
+ {
+ $events = new InMemoryEventRepository();
+ for ($i = 0; $i < 3; $i++) {
+ $events->insert(['device_id' => 'pi-01', 'camera_id' => 'cam-01', 'event_type' => 'fall',
+ 'severity' => 'high', 'occurred_at' => (float)(time() - 100), 'site' => null]);
+ }
+ $audit = new InMemoryAuditRepository();
+ $sess = new ArraySessionStore();
+ $sess->set('user_id', 7); $sess->set('username', '관리자');
+ $sess->set('role', 'admin'); $sess->set('site_scope', null);
+
+ $export = new ExportController($events, new InMemorySnapshotRepository($this->dir), $sess, $audit);
+ $res = $export->labels(new Request('GET', '/api/export/labels', [], '', ['days' => '30']));
+
+ $this->assertSame(200, $res->status());
+ $this->assertCount(1, $audit->log, '최대 2만 건이 파일 하나로 나가는데 기록이 없습니다.');
+ $this->assertSame('label_export', $audit->log[0]['act']);
+ $this->assertSame(30, $audit->log[0]['af']['days']);
+ $this->assertSame(3, $audit->log[0]['af']['rows']);
+ }
+
+ public function test_a_forbidden_export_is_not_recorded_as_one(): void
+ {
+ $sess = new ArraySessionStore();
+ $sess->set('user_id', 7); $sess->set('username', '작업자');
+ $sess->set('role', 'viewer'); $sess->set('site_scope', null);
+ $audit = new InMemoryAuditRepository();
+
+ $export = new ExportController(new InMemoryEventRepository(),
+ new InMemorySnapshotRepository($this->dir), $sess, $audit);
+ $res = $export->labels(new Request('GET', '/api/export/labels', [], '', []));
+
+ $this->assertSame(403, $res->status());
+ $this->assertSame([], $audit->log);
+ }
+}
diff --git a/server/tests/RouteTableTest.php b/server/tests/RouteTableTest.php
index 42ba3d1..96a02cf 100644
--- a/server/tests/RouteTableTest.php
+++ b/server/tests/RouteTableTest.php
@@ -34,6 +34,7 @@ final class RouteTableTest extends TestCase
'POST /api/events', 'POST /api/heartbeat', 'GET /api/config', 'GET /api/csrf',
'POST /login', 'POST /logout', 'GET /api/events', 'GET /api/devices',
'GET /events/{id}', 'POST /events/{id}/ack', 'POST /events/{id}/false-positive',
+ 'POST /events/{id}/hold',
'GET /api/zones/{camera}', 'GET /api/zone-presets',
'GET /api/heatmap/{camera}', 'GET /api/fp-stats',
'GET /api/report', 'GET /api/export/labels', 'GET /api/actions', 'POST /api/actions',
diff --git a/server/tests/Support/InMemoryRepositories.php b/server/tests/Support/InMemoryRepositories.php
index 329ee9d..ffaf9f7 100644
--- a/server/tests/Support/InMemoryRepositories.php
+++ b/server/tests/Support/InMemoryRepositories.php
@@ -67,6 +67,11 @@ final class InMemoryEventRepository implements EventRepository {
}
public function find(int $id): ?array { return $this->rows[$id] ?? null; }
public function acknowledge(int $id, string $u): bool { if (!isset($this->rows[$id])) return false; $this->rows[$id]['acknowledged_by'] = $u; $this->rows[$id]['acknowledged_at'] = $this->rows[$id]['acknowledged_at'] ?? time(); return true; }
+ public function setHold(int $id, ?int $until): bool {
+ if (!isset($this->rows[$id])) return false;
+ $this->rows[$id]['hold_until'] = $until;
+ return true;
+ }
public function markFalsePositive(int $id, string $user): bool {
if (!isset($this->rows[$id])) return false;
$r = $this->rows[$id];
@@ -210,11 +215,14 @@ final class InMemoryEventRepository implements EventRepository {
}
final class InMemorySnapshotRepository implements SnapshotRepository {
public array $rows = []; public array $paths = []; public array $created = [];
+ /** 실제 파일이 있어야 하는 테스트(SnapshotProxy 는 is_file() 을 본다)가 쓸 디렉터리.
+ * 기본값은 예전 그대로 /tmp — 경로 문자열만 쓰던 기존 테스트가 그대로 돈다. */
+ public function __construct(private string $dir = '/tmp') {}
public function add(int $e, string $uuid, string $hash): void {
// Mirror the DB's UNIQUE (event_id, content_hash): a re-sent event's identical bytes
// collide instead of duplicating, so the dedup recovery path is idempotent.
foreach ($this->rows[$e] ?? [] as $r) { if ($r['content_hash'] === $hash) return; }
- $this->rows[$e][] = ['file_uuid' => $uuid, 'content_hash' => $hash]; $this->paths[$uuid] = "/tmp/$uuid.jpg"; $this->created[$uuid] = time();
+ $this->rows[$e][] = ['file_uuid' => $uuid, 'content_hash' => $hash]; $this->paths[$uuid] = rtrim($this->dir, '/\\') . "/$uuid.jpg"; $this->created[$uuid] = time();
}
public function forEvent(int $e): array { return $this->rows[$e] ?? []; }
public function thumbUuidsFor(array $eventIds): array {
diff --git a/server/tools/dispatch_alerts.php b/server/tools/dispatch_alerts.php
index 04d54c9..b62ec6f 100644
--- a/server/tools/dispatch_alerts.php
+++ b/server/tools/dispatch_alerts.php
@@ -32,7 +32,7 @@ require __DIR__ . '/bootstrap.php';
use App\Alert\{AlertDispatcher, Channels, DeviceHealthWatch, EscalationWatch, MultiDownWatch};
use App\Repository\Pdo\{PdoEventRepository, PdoAlertRepository, PdoSuppressionRepository,
- PdoDeviceRepository, PdoDeviceMetricRepository};
+ PdoDeviceRepository, PdoDeviceMetricRepository, PdoAppStateRepository};
[$cfg, $pdo] = tool_boot();
$args = tool_args($argv);
@@ -179,6 +179,24 @@ if (!$dryRun) {
}
}
+// --- 5. 생존 기록 ---------------------------------------------------------------------
+// 여기까지 왔다는 것은 이 프로세스가 돌았다는 뜻이다. 통보·확대·장치 이상 발행이 전부 이 한
+// 프로세스에 걸려 있어서, 크론이 멈추면 화면은 그냥 조용해지고 그 침묵은 "사고 없음"과
+// 구별되지 않는다. 죽은 노드를 하트비트의 부재로 판정하는 바로 그 논리를 우리 자신에게도
+// 적용한다 — 감시자의 생존은 감시자가 아닌 쪽(대시보드)이 읽는다.
+//
+// 단계별 예외 처리 밖에, 마지막에 무조건 남긴다: 어느 단계가 실패했더라도 프로세스는 살아
+// 있었고, 여기서 구별하려는 것은 "통보가 틀렸다"가 아니라 "아무도 안 돌고 있다"이다.
+// dry-run 은 남기지 않는다 — 사람이 손으로 확인해 본 것을 크론이 도는 증거로 삼으면,
+// 크론이 죽은 뒤에도 화면이 초록으로 남는다.
+if (!$dryRun) {
+ try {
+ (new PdoAppStateRepository($pdo))->set('dispatch_alerts.last_run', (string)$now, $now);
+ } catch (Throwable $e) {
+ fwrite(STDERR, date('c') . ' 생존 기록 실패(통보에는 영향 없음): ' . $e->getMessage() . PHP_EOL);
+ }
+}
+
fwrite(STDOUT, sprintf(
"%s considered=%d sent=%d suppressed=%d failed=%d skipped=%d retried=%d escalated=%d health=%d multi=%d%s\n",
date('c'), $stats['considered'], $stats['sent'], $stats['suppressed'],
diff --git a/tests/test_app.py b/tests/test_app.py
index 6e255be..b8ab8dd 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -246,3 +246,54 @@ def test_two_persons_get_independent_smoothing():
# state leaked between tracks.
assert left_nose_x < 300
assert right_nose_x > 700
+
+
+def test_overstay_goes_out_without_evidence_while_the_same_batch_keeps_it():
+ """체류 상한 초과에는 사진이 붙지 않는다 — 같은 배치의 구역 침입에는 붙는 채로.
+
+ 상수만 검사하면 배선이 끊겨도 초록이 뜬다(이 프로젝트가 감사마다 잡아온 모양이다).
+ 그래서 실제 run_once 를 태워, 한 번의 전송에서 한쪽은 증거를 들고 다른 쪽은 안 드는지를
+ 본다. 체류 초과의 발화 조건은 시간 경과 하나뿐이라, 여기 사진이 붙으면 정상 작업자가
+ 규정대로 일하는 동안 주기적으로 본인 사진이 서버로 가는 시스템이 된다.
+ """
+ from edge.detectors.zone import Zone
+ from edge.detectors.occupancy import OccupancyWatchdog, OccupancyConfig
+
+ class _SnapSink:
+ def __init__(self): self.sent = []
+ def try_send(self, event, snapshots):
+ self.sent.append((event, snapshots)); return True
+
+ poly = [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)] # 프레임 전체
+ zone = Zone("z1", "restricted", poly, {"max_dwell_s": 1.0})
+
+ src = _Source()
+ pose = _ScriptedPose([[_person_at(320)], [_person_at(320)]])
+ engine = EventEngine(
+ FallDetector(FallConfig(prone_confirm_s=999)),
+ ImmobilityWatchdog(ImmobilityConfig(immobile_s=999)),
+ GhostIncidentDetector(GhostConfig()),
+ ZoneIntrusionDetector([zone], ZoneConfig()),
+ EngineConfig(camera_id="cam-01"),
+ # 기본 설정 그대로. 바꾸는 것은 존 규칙(현장 설정)인 max_dwell_s 뿐이다.
+ occupancy=OccupancyWatchdog(OccupancyConfig()))
+ ring = RingBuffer(); snap = Snapshotter(FaceBlurrer()); sink = _SnapSink()
+
+ # 트래커·스무더는 프레임 간에 공유한다 — 체류는 "같은 트랙이 계속 있었는가"라서,
+ # 매 호출 새 트래커를 주면 실제와 다른 조건에서 통과할 수 있다.
+ tracker, pool = IouTracker(), TrackSmootherPool()
+ times = iter([0.0, 1.5])
+ def now_fn(): return next(times)
+ run_once(src, pose, tracker, pool, engine, ring, snap, sink, now_fn)
+ run_once(src, pose, tracker, pool, engine, ring, snap, sink, now_fn)
+
+ by_type = {e.event_type: s for e, s in sink.sent}
+ assert EventType.OVERSTAY in by_type, "체류 상한 초과가 발화하지 않았습니다 — 테스트가 무의미합니다"
+ assert by_type[EventType.OVERSTAY] == [], \
+ "체류 상한 초과에 사진이 붙었습니다. 오래 있었다는 이유로 작업자 사진이 남습니다."
+
+ # 대조군: 같은 파이프라인에서 진짜 사건에는 증거가 붙어 있어야 한다. 이게 없으면
+ # 위 단언은 "증거 기능이 통째로 죽었다"로도 통과한다.
+ assert EventType.ZONE_RESTRICTED in by_type, "구역 침입이 발화하지 않았습니다"
+ assert by_type[EventType.ZONE_RESTRICTED], \
+ "구역 침입에서 증거가 사라졌습니다 — 사고 조사에서 볼 것이 없어집니다."
diff --git a/tests/test_evidence_policy.py b/tests/test_evidence_policy.py
new file mode 100644
index 0000000..2935d1f
--- /dev/null
+++ b/tests/test_evidence_policy.py
@@ -0,0 +1,55 @@
+"""사진이 붙는 이벤트와 붙지 않는 이벤트.
+
+이 경계는 성능이나 용량 문제가 아니라 우리가 작업자에게 한 약속이다 — "정상 근무 중에는
+아무것도 남지 않는다". 발화 조건이 시간 경과 하나뿐인 유형(체류 상한 초과)에 사진을 붙이면
+그 약속이 거짓이 되고, 시스템은 30분 넘게 그 구역에 있었다는 이유만으로 그 사람의 사진 3장을
+서버에 남긴다. 위험해서가 아니라 오래 있어서.
+
+반대 방향도 같은 무게로 잠근다: 넘어짐에서 사진이 빠지면 사고 조사에 쓸 것이 없다.
+"""
+import pytest
+
+from edge.app import NO_EVIDENCE_TYPES
+from edge.events import EventType
+
+
+# 사진이 사건의 증거인 유형 — 무슨 일이 일어났는지가 프레임 안에 있거나(넘어짐·협착),
+# 규칙 위반 자체가 인원 수라서 프레임이 그것을 증명한다(단독작업·정원 초과).
+EVIDENTIAL = [
+ EventType.FALL, EventType.FALL_FROM_HEIGHT, EventType.NO_MOTION, EventType.IMMOBILITY,
+ EventType.GHOST, EventType.NEAR_FALL, EventType.PINCH,
+ EventType.ZONE_WARNING, EventType.ZONE_RESTRICTED,
+ EventType.LONE_WORKER, EventType.OVERCAPACITY,
+]
+
+
+@pytest.mark.parametrize("etype", EVIDENTIAL, ids=lambda e: e.value)
+def test_incident_types_carry_evidence(etype):
+ assert etype not in NO_EVIDENCE_TYPES, (
+ f"{etype.value} 에서 증거 프레임이 빠졌습니다. 사고 조사에서 볼 것이 사라집니다.")
+
+
+@pytest.mark.parametrize("etype", sorted(NO_EVIDENCE_TYPES, key=lambda e: e.value),
+ ids=lambda e: e.value)
+def test_non_incident_types_carry_no_evidence(etype):
+ # 목록에 든 것이 의도한 넷인지 고정한다. 새 유형을 여기 넣으려면 이 테스트를 고쳐야 하고,
+ # 그 순간 "왜 이 유형은 사진이 필요 없는가"를 한 번 더 답하게 된다.
+ assert etype in {
+ EventType.ERGO_REPORT, EventType.OVERSTAY,
+ EventType.DEVICE_FAULT, EventType.MONITORING_DEGRADED,
+ }
+
+
+def test_overstay_is_excluded_because_time_alone_is_not_an_incident():
+ """가장 중요한 한 줄 — 되돌리면 감시 시스템이 된다.
+
+ 체류 상한 초과는 사람이 구역에 머무른 시간만으로 발화한다. 프리셋 기본값은 밀폐공간
+ 30분·화기작업 60분이라, 사진을 붙이면 정상 작업자가 규정대로 일하는 동안 주기적으로
+ 본인 사진이 서버로 가는 시스템이 된다.
+ """
+ assert EventType.OVERSTAY in NO_EVIDENCE_TYPES
+
+
+def test_the_exclusion_list_stays_small():
+ # 이 목록이 길어지고 있다면 경계가 "증거냐 기록이냐"에서 다른 무언가로 밀린 것이다.
+ assert len(NO_EVIDENCE_TYPES) <= 6