앞선 감사 네 번은 "이 코드가 제대로 도는가"를 물었다. 이번엔 작업자·노조·안전관리자·
공장장·설치기사 5인 관점으로 3단논법을 세우고 전제를 코드로 확인한 뒤 반문으로 깼다.
5인 중 2인의 논증이 완전히, 2인이 부분적으로 무너졌다.
읽기 감사 — 이 시스템은 쓰기를 전부 감사했다. 임계값 한 번 바꾼 것까지 해시 체인에
들어간다. 그런데 작업자 사진을 열어보는 것은 하나도 남지 않았다. 감시 시스템과 안전
시스템을 가르는 선이 정확히 거기라서, 나머지 프라이버시 설계(RAM 링버퍼 7.5초·전송 전
얼굴 블러·라이브뷰 부재)가 전부 이 한 줄이 없어서 검증 불가능한 주장에 머물러 있었다.
거부된 열람은 기록하지 않는다 — 그러면 감사 로그가 "본 사람"이 아니라 "요청한 사람"
목록이 되어 정작 누가 봤는지를 묻는 데 못 쓴다.
크론 생존 — 죽은 노드는 스스로 신고할 수 없어서 크론이 하트비트의 부재로 판정한다.
그런데 그 크론이 멈추면 아무도 판정하지 않는다. 통보·5분 확대·장치 이상 발행이 전부 그
한 프로세스에 걸려 있어서, 멈춘 순간부터 화면은 조용해지고 그 침묵은 "사고 없음"과
구별되지 않는다. 감시자의 생존은 감시자가 아닌 쪽이 읽어야 한다.
증거 보존 — 스냅샷 삭제가 나이 하나만 봤다. 기본 7일인데 중대재해 조사는 몇 주에서 몇
달이라, 우리가 내건 목적("사고 후 원인 분석")이 8일째에 스스로 사라졌다. 확인 처리로
자동 연장하지 않는 이유는 자동이면 사실상 전량 보존이 되어 7일 약속이 무너지기 때문이다.
증거 정책 — 프레임이 ergo_report 하나만 빼고 모든 이벤트에 붙었다. 체류 상한 초과도
포함이라, 밀폐공간 프리셋 기본값(30분) 기준으로 정상 작업자가 규정대로 일하는 동안
주기적으로 본인 사진이 서버로 갔다. 위험해서가 아니라 오래 있어서. 기준을 명문화했다:
시간 경과 자체가 조건인 이벤트에는 사진을 붙이지 않는다.
마이그레이션 2026-09-25-evidence-hold-and-view-audit.sql (README-UPLOAD 10번).
검증: phpunit 302 passed(신규 18, 스킵 0) · pytest(신규 18)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBjuR7DW6PNNMows4KnbYU
Two operator-value features: evidence that shows HOW it happened, and the
one-line answer to "what happened overnight".
Evidence timeline (closes the P1-B "前中後 스냅샷 배선" follow-up):
- The ring buffer now stores per-frame head boxes, and an event captures the
frames nearest T-2.5s / T-1.0s / T-0 (server cap: 3 snapshots). A fall's
evidentiary value is in the seconds BEFORE the person hit the floor - one
end-state frame never showed how it happened.
- Each past frame is blurred with the head positions of ITS OWN moment -
blurring history with the current tick's boxes would leave a face exactly
where it actually was (PIPA).
- Right after startup the ring is short: fewer frames go out, never zero and
never duplicated. Detail view captions the sequence 이전/직전/발생 시점 by
position (offsets are edge config the server doesn't know).
24h catch-up brief:
- The events console renders one line above the stream on load - "지난 24시간
- 감지 N건 (중대 n) · 니어미스 n · 평균 대응 t · 미확인 n건 남음" - reusing
GET /api/report?days=1, with a link into /report. High-tinted when the
window held critical/high events; hidden entirely until data arrives.
Verified: pytest 235 pass (3 new: timeline selection, short-buffer dedup,
3-frame capture through run_once) / PHPUnit 138 pass + Chrome real-render
(brief line with severe tint on the console; detail page showing the
3-frame timeline with captions; no console errors).
Same pose stream, new safety axis: musculoskeletal-load prevention. This is an
honest 2D reduction of REBA - band exposure TIME, not a precise REBA score.
Edge:
- geometry: arm_elevation_deg (shoulder->elbow vs vertical-down, best of both
arms) and knee_flexion_deg (hip-knee-ankle interior angle, worst of both
legs). Both return None when no confident side exists - "unmeasurable" must
never read as "0 deg / straight".
- detectors/ergonomics.py: per-frame dt accumulation into bands (trunk 20-60
and 60+ deg via the existing torso angle, arm 90+ deg, knee <110 deg), gated
on confident core keypoints; detection gaps are dt-capped (2s) so a dropout
never counts as exposure. Hourly per-camera report, skipped when nothing was
observed (an empty-workshop report is noise).
- Engine: tracks in an incident state (FALLING/FALLEN/NO_MOTION) are excluded
- a fallen worker's 90-deg trunk is an incident, not an ergonomic exposure.
The report event (ergo_report/INFO) carries no track_id and no spatial:
a camera-level anonymous aggregate, per the PIPA-first product stance.
- run_once: NO snapshots on ergo_report (a frame would turn a statistic into
surveillance imagery); capture is skipped entirely when only ergo fired.
Server/UI: ergo_report accepted (validator + chk_events_type, edited in the
still-undeployed heatmap migration); detail view now renders event metrics
with Korean labels and seconds humanized - the ergo report's whole value IS
its metrics. Korean type label in list + detail.
Verified: pytest 232 pass (10 new) / PHPUnit 135 pass. NOTE: the detail-view
visual check is pending - the Chrome extension disconnected mid-verification;
harness is staged under the session scratchpad (events/1/view).
- P2-6 tracking: global greedy IoU matching (all pairs, best overlap first)
replaces track-iteration-order greedy, so a crossing worker's box can no
longer be stolen by whichever track came first. Any track in a contended
match (person overlapping 2 tracks, or track overlapping 2 persons) is
reported via tracker.ambiguous(); run_once resets its detector + smoother
state so FALLEN/baseline/immobility never ride an id swap onto the wrong
person. A genuinely fallen worker re-confirms within prone_confirm_s.
- P2-7 fall: STANDING -> FALLING now requires sustained prone
(falling_confirm_s, 1.0s). One prone-looking frame (a bend, a bbox glitch)
no longer arms the ghost detector's CRITICAL if the person then leaves.
FALLEN / no_motion timing is unchanged (keyed off prone_since).
- P2-8 app: run_once feeds the engine and smoothers only tracks re-detected
this tick; a track coasting inside max_age with a frozen frame is no-data,
so prone-confirm / no-motion / immobility timers cannot accrue on stale
data. Re-detection still confirms the fall (dropout pauses evidence).
TDD: 7 new tests verified failing on the pre-fix code (git stash), then
203 passed / 2 skipped (hardware) with the fixes.
- Split clocks: detector timers/cooldowns run on a MONOTONIC clock (immune to an
NTP step on an RTC-less Pi); wall time is used only for the HMAC X-Timestamp
and Event.occurred_at. Fixes false IMMOBILITY / dropped tracks after a clock jump.
- Heartbeat is time-based (30s, predictable regardless of load) and now carries
temp/throttle/queue_pending/config_version telemetry.
- A dead (no-frame) camera counts as erroring instead of a healthy frame, so a
silent monitoring outage no longer looks green.
- Offline queue: isolate one undecodable row instead of wedging every event behind
it; classify 401 (stale clock) as transient, never a poison-drop.
- New edge/health.py: vcgencmd temp/throttle parsing, None off-Pi (no fake 0).
edge/app.py run_once used ONE shared KeypointSmoother across all persons
BEFORE tracking assigned IDs. The smoother holds per-joint-index One-Euro
filter state, so with >1 person in frame the filter state was shared and
corrupted across different people, and same-timestamp calls within a frame
would pass through unsmoothed while still clobbering the shared state.
Multi-person scenes could get corrupted torso angles -> missed/false falls.
Add TrackSmootherPool (edge/smoothing.py) that lazily allocates one
KeypointSmoother per track_id. run_once now: tracks raw keypoints first,
then smooths each track's latest frame in place using its own pooled
smoother, and forgets a track's smoother when the track is lost (ties into
FIX 5 lost-track cleanup).
BEHAVIOR CHANGE: keypoints are now smoothed after tracking instead of
before. Single-person pipelines are unaffected (still one smoother, one
identity). Multi-person pipelines get correct, non-cross-contaminated
smoothing.
run_once signature: smoother param replaced with pool: TrackSmootherPool.
build_pipeline now constructs "pool" instead of "smoother".
Tests: tests/test_smoothing.py adds TrackSmootherPool identity/forget
coverage; tests/test_app.py adds test_two_persons_get_independent_smoothing
demonstrating each track's smoothed keypoints stay on its own side instead
of being pulled toward the other person.