4개 관점 병렬 감사 후 각 발견을 반증 시도해 확정. 반증된 4건은 감사 문서에
REFUTED 로 남겼다(히트맵 왜곡·에스컬레이션 전파·스트림 필터 고정·설정 미배선).
CRITICAL 2건 — 둘 다 이번 기능이 만든 회귀다:
R3-1 dispatch_alerts.php 의 장치 건전성 단계가 try 밖에 있었다. 이 단계만
move_devices·move_device_metrics 를 읽는데 통보 경로는 그 두 표를 건드리지
않는다 — 즉 마이그레이션 미적용·커넥션 한도·lock timeout 이 원래대로면 정상
발송됐을 실행을 통째로 죽인다. 원인이 지속형이라 매분 반복되고 콘솔은 멀쩡해
보인다. 1·2단계를 각각 격리했고, 3단계의 exit(1) 도 없앴다 — 거기 도달하는
예외는 저장소 장애이고 4단계 재시도가 바로 그것에서 회복하려고 존재한다.
R3-2 간헐 고장에 감지기가 양방향으로 무너졌다. edge/daemon.py:105 는 성공
프레임 하나에 streak 를 지워 cameras_erroring 를 0 으로 되돌린다. 정상 표본
하나로 저하 구간을 끊으니 (a) 두 시간 고장에도 sustain 을 못 넘어 아무것도
못 올리고 (b) 끊긴 구간마다 새 앵커가 생겨 6시간에 54건을 쏟아냈다. 엣지
낙상 감지기의 recover_grace_s 와 같은 규율로 짧은 정상을 흡수하고, uuid 에
재고지 버킷을 더해 주기당 1건으로 묶었다(같은 입력 ≤7건). 기존 16개 테스트가
전부 단조 시퀀스라 이 결함이 CI를 통과했다 — 그래서 플래핑·폭주·회복·미래시각
테스트를 추가했다.
HIGH 5건: 센티넬이 메일·웹훅으로 회사 밖에 나갔고(라벨을 app.js 에만 넣은 것이
원인), 사건 상세 링크로 존재하지 않는 카메라에 구역을 저장할 수 있었고, 인제스트가
센티넬을 예약값으로 안 막아 엣지가 그 camera_id 로 올린 진짜 낙상이 화면에서
더 안 보이게 됐고, BUILD-INFO 가 릴리스 이전 커밋을 가리켰고, 실제 자격증명
파일이 납품 폴더 안에 있었다.
값이 흩어진 것이 센티넬 결함의 근본 원인이라 단일 출처를 만들었다:
App\DeviceScope(서버) + assets/device-scope.js(프런트) + PdoEventRepository 의
NOT_DEVICE SQL 조각. 셋이 어긋나면 DeviceScopeTest 가 잡는다.
MEDIUM 7건: 카메라별 집계 3곳 제외, 설정 점검 오판정, 개선조치 대상 거부,
라벨 CSV 제외, 낡은 지표로 판정 금지, 미래 시각 표본 제거, 죽은 노드 재고지.
검증: phpunit 250 passed (신규 10) · pytest 는 dist 재생성 후 확인.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBjuR7DW6PNNMows4KnbYU
1.0.0 이후 edge/ 는 한 줄도 바뀌지 않았다(git diff 확인). 바뀐 것은 서버뿐이라
서버 전용 릴리스로 낸다.
왜 버전을 올리는가: dist/ 재생성으로 DeviceHealthWatch 가 이미 패키지에 들어가
있는데 VERSION 은 1.0.0 이었다. 그대로 두면 "1.0.0" 이름표를 단 서로 다른
패키지가 두 종류 생긴다. 기능 추가이므로 1.1.0.
- VERSION 1.0.0 -> 1.1.0
- CHANGELOG "미출시 (main)" 절을 1.1.0 으로 확정
- dist 재생성 + 구버전 ZIP 제거(같은 폴더에 두 버전이 있으면 잘못 집는다)
-> move-detect-cafe24-1.1.0-20260904.zip
- 사용설명서·경영진 덱·소개서 재생성 (전부 VERSION 을 표기에 쓴다)
Pi 번들은 이번 릴리스에서 갱신하지 않았다. edge/ 가 불변이라 내용은 1.0.0 과
동일하고, 재빌드는 arm64 + 네트워크(pip download, fetch_hailo_hef)가 있는 빌드
머신에서만 가능하다. dist/ 에는 1.0.0 타르볼이 그대로 남아 있으므로, Pi 를
납품할 때 그 머신에서 재빌드해 파일명 버전을 맞춰야 한다.
검증: pytest 308 passed / phpunit 240 passed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBjuR7DW6PNNMows4KnbYU
받은 v01(넘어짐·경광등)·v02(앉기·정상)는 내용상 브리프대로였다. 컨택트
시트로 확인: v01 0.79초 쓰러짐 → 1.58초 경광등 점등 → 유지, v02 4초 내내
경광등 꺼짐. 둘 다 CCTV 앵글, 얼굴 식별 불가.
다만 HEVC(H.265)로 인코딩돼 있어 브라우저가 디코딩하지 못했다. 같은 페이지
같은 브라우저에서 대조:
HEVC .mp4 -> readyState 0, videoWidth 0, duration NaN, error 이벤트 없음
VP9 .webm -> readyState 1+, videoWidth 1600, duration 4.04
서버(Range 미지원 가능성)를 배제하려 blob 으로 통째로 올려도 HEVC 는 같았다.
오류를 내지 않는 실패라 리허설 없이는 회의실에서야 알게 되는 종류다.
docs/deck/prepare_clips.py 신설 — 원본을 VP9/WebM 으로 변환(ffmpeg·openh264
없어 H.264 인코딩 불가, avc1/H264/X264 전부 VideoWriter 열기 실패)하고 클립
자신의 마지막 프레임을 포스터로 뽑는다. 원본 mp4 도 clips/ 에 남겨 두고
build_deck 이 <source> 를 webm→mp4 순으로 내보내 환경에 맞는 쪽이 재생된다.
포스터를 클립에서 뽑는 이유: deck.json 의 포스터는 클립이 없을 때 쓰는 대체
도판이라 클립이 생기면 어긋난다 — 비교 슬라이드에서 좌우 비교 도판이 좌우
패널 각각에 들어가 비교가 두 겹이 됐다.
cv2.imwrite 는 Windows 에서 한글 경로에 쓰지 못한다(예외 없이 False). 이
프로젝트는 파일명이 전부 한글이라 포스터가 조용히 하나도 안 만들어졌다.
imencode + write_bytes 로 우회.
확인하지 못한 것: 재생 진행 여부. 자동화 브라우저 컨텍스트가 디코딩까지만
허용하고 currentTime 이 0 에서 움직이지 않는다(자기가 MediaRecorder 로 만든
WebM 도 동일). 발표 노트북에서 한 번 눌러 확인해야 한다. 재생이 안 되더라도
poster 가 남아 슬라이드가 비지는 않는다.
영상 브리프에 형식 요구(H.264, HEVC 금지)와 변환 절차를 반영. 남은 P1 2종
(v04 구역진입·v05 카메라 건드림) 자리는 계속 스틸로 대체된다.
받은 이미지 11장을 docs/images/ 로 최적화 배치(37.6MB -> 3.3MB: 사진은
JPEG q88, 평면 다이어그램은 PNG-8, 최대폭 1600). 원본은 image/ 에 그대로 둔다.
소개서: 매니페스트(docs/소개서-이미지.json)로 11장을 절에 배치해 HTML 재생성
(4.6MB 단일 파일). 표지 컷은 1장 앞에 before-heading 으로.
경영진 보고 덱(docs/deck/): 14장. 매뉴얼 생성기와 같은 구조 —
deck.json(내용) + build_deck.py(생성기) + clips/(3초 클립). 손으로 쓴 HTML 은
두 번째 수정부터 낡고, 낡은 덱은 회의실에서 틀린 숫자를 띄운다.
클립이 없으면 포스터 스틸로 자동 대체되므로 지금 상태로도 완성본이다.
비교 슬라이드는 클립이 하나도 없으면 이미 좌우 비교인 09 스틸 1장으로
바뀐다 — 좌우 패널에 쪼개 넣으면 같은 비교가 두 겹이 된다.
모든 클립·스틸에 '재현' 배지를 템플릿에 박았다. 이 영상들은 제품 동작
녹화가 아니라 연출된 재현이고, 실제 동작처럼 보여주면 허위 시연이 된다.
배지 문구는 매체를 따른다(영상/이미지) — 스틸에 "재현 영상"은 그 자체가
작은 거짓말이다. 실화면 캡처 8장에는 배지가 붙지 않는다.
실렌더로 확인하며 잡은 결함:
- 마지막 슬라이드가 백지였다. :nth-child(even) 이 section.closing 보다
명시도가 높아 네이비 배경을 덮고, 흰 글자가 흰 배경에 얹혔다(대비 1:1).
- 한국어 제목이 어절 중간에서 끊겼다("설명할 수 있습/니다") — CJK 기본
줄바꿈이 글자 단위다. word-break:keep-all.
- 슬라이드 이동이 전혀 동작하지 않았다. scroll-snap-type:y mandatory 인
컨테이너에서 behavior:'smooth' 가 취소되고 원위치로 스냅된다
(측정: smooth->0, auto->905). 즉시 전환으로 변경 — 덱에서는 컷 전환이
오히려 슬라이드답다. 키보드가 항상 잡히는 것은 아니라 이전/다음 버튼도 추가.
- 도판이 커서 하단 주석이 화면 밖으로 밀렸다(max-height 하향).
- 표지 베일이 무거워 경광등 빨강이 죽었다 — 이 제품의 상징색이라 살렸다.
- 4번과 5번이 같은 도판을 썼다(포스터 교체).
접근성 게이트(측정): 대비 실패 0건. figcaption 4.35:1 미달로 --ink-3 을
#5C6474 로, 네이비 위 슬라이드 번호 2.0:1 을 #8FB2E2 로 교정. 레일 점은
7x7 이라 발표 중 못 맞춘다 — 점은 그대로 두고 히트영역 24x24(WCAG 2.5.8),
이전/다음 버튼 46x46.
영상 브리프(docs/영상-브리프.md): 3초 클립 6종. 스틸이 못 하는 것만 발주
했고, 클립마다 0.0~3.0초 타임라인·프레임 안에 반드시 있어야 할 것·피할 것을
적었다. 대시보드 녹화는 제외 — 실화면 캡처가 이미 있고 그건 진짜다.
생성물(경영진-보고.html)과 clips/ 는 gitignore — 사용설명서.html 과 같은 이유.
두 유형은 타입 정의(edge/events.py)·서버 검증(EventValidator)·콘솔 필터·
통보 라벨까지 전 구간에 있었으나 생성하는 코드가 어디에도 없었다. 실제로
값을 넣는 것은 seed_demo.php 뿐이었고, 소개서가 내세운 "17종"은 15종이었다.
앞서 고친 EventValidator 누락과 정확히 반대 방향의 같은 병이다. 그때는
감지기가 있는데 서버가 막았고, 이번엔 서버·UI가 열려 있는데 감지기가 없다.
어느 쪽이든 화면은 멀쩡해 보인다.
엣지가 낼 수 없는 유형이라 서버에 둔다 — 전원·회선이 끊긴 노드는 자기가
죽었다고 신고할 수 없다. 하트비트의 부재로만 관측된다.
App\Alert\DeviceHealthWatch:
- dispatch_alerts.php 1분 크론의 1단계. 에스컬레이션·통보보다 앞에 두어
같은 실행 안에서 발행된 장치 이벤트가 곧바로 통보 대상이 되게 했다.
- 멱등은 EscalationWatch와 같은 방식(event_uuid UNIQUE 충돌)이되, 장치
건전성은 연속 상태라 uuid 앵커를 '장애 구간의 시작'으로 잡았다.
device_fault는 last_heartbeat_at(장애 내내 불변) → 장애당 정확히 1건.
monitoring_degraded는 창 안의 마지막 정상 하트비트 ts. 창 전체가 저하면
시작을 볼 수 없으므로 창 버킷으로 폴백해 시간당 1회 재고지한다 —
의도된 리마인더이고, 매 틱 재발행하는 앵커 드리프트가 아니다.
- 성능 저하는 지속을 요구(기본 5분). 한 번 튄 값에 울리면 운영자가 이
유형을 무시하게 되고, 그러면 감시가 멈춘 날에도 아무도 보지 않는다.
엣지 감지기들이 쓰는 규율과 같다.
- 하트비트를 한 번도 보낸 적 없는 장치는 장애가 아니다(미설치 노드).
새 현장 첫날이 빨간 벽이 되면 그 사이트는 이 유형을 영영 신뢰하지 않는다.
- 죽은 노드에 degraded를 겹쳐 내지 않는다 — 한 고장에 두 알람은 소음이다.
- fps NULL을 0으로 강압하지 않는다(측정 실패 ≠ 정지). PdoDeviceMetric
Repository가 NULL을 지키는 이유와 같다.
- 억제(정비 모드)는 여기서 보지 않는다. 기록은 남기고 통보 여부는
AlertDispatcher가 정한다 — 정비 창은 전화를 끄는 것이지 노드가 죽었던
기록을 지우는 것이 아니다.
camera_id는 NOT NULL인데 장치 단위 이벤트에는 카메라가 없다. '__device__'
센티넬을 쓰고, 콘솔은 "장치 전체"로 표시, 구역 편집기 카메라 목록에서는
제외한다(카메라 목록은 보고된 이벤트에서 파생되므로, 두면 존재하지 않는
카메라에 구역을 그리려는 일이 생긴다).
설정 4종 추가(device_stale_seconds 180 · degraded_sustain_seconds 300 ·
degraded_fps_ratio 0.6 · target_fps 12) — 두 config 템플릿에 한국어 설명과
함께. 낮은 프레임으로 운용하는 현장이 target_fps를 안 맞추면 전 노드가
영구 저하로 보고되므로 그 경고를 템플릿에 적었다.
검증: pytest 308 passed(3 skip: 모델 미동봉·shellcheck 미설치·AF_UNIX,
기존과 동일) · phpunit 240 passed(신규 16). dist 재생성으로 패리티 회복.
브리프2: 새로 추가한 감지 장면 4장만 독립 문서로. 1차 브리프에서 §B를
떼어내되 공통 규칙·수치 주의·발주 순서를 다시 실어 단독으로 성립하게 했다.
영상 보관정책: 노조의 "감시체제 아니냐" 공격에 대한 대응 논리.
핵심은 질문의 전제가 이 제품에 적용되지 않는다는 것 — 영상을 녹화하지
않는다. 링버퍼는 RAM 90프레임이고 디스크에 쓰지 않으며, 이벤트 때만
얼굴 블러된 3장이 서버로 가고 7일 후 삭제된다(코드 근거 표로 정리).
'보관하되 아무도 못 본다'는 최악의 선택이라고 결론. 그것은 행동에 대한
약속이라 검증 불가능하고 관리자 설정 하나로 뒤집힌다 — 노조의 불신은
정확히 그 지점이다. 미수집은 되돌릴 수 없다는 것이 유일하게 강한 카드다.
'하루치 보관해 조짐 관찰'은 권하지 않음(5개 이유). 상시 녹화를 시작하는
순간 기존 CCTV의 열등한 복제품이 되고 고유 포지션을 잃는다.
진짜 쟁점은 기간이 아니라 접근 구조라는 점을 명시. 그 과정에서 제품
공백 하나 확인: 스냅샷 열람이 감사기록에 남지 않는다(현재 audit->append
호출부는 쓰기 동작뿐). 노조 협의에서 가장 강한 카드가 될 항목이라 보완
권고로 남겼다.
법령 근거(표준지침 41조 30일 기본값, 산안법 164조 3년, 근참법 20조
감시설비 협의사항, 인권위 2017)는 출처 링크와 함께 참고로만 적고,
노무사·변호사 확인 필요를 문서 상단에 명시했다.
브리프에 "언제 감지되는가"에 답하는 이미지가 하나도 없었다. 쓰러진 사람
'사진'을 부적절하다고 제외하면서 '감지 시점을 설명하는 그림'까지 함께
빠졌다. 둘은 다르다 — 일러스트로 그린 사고 장면은 프라이버시 제약과
충돌하지 않고, 안전 자료에서는 표준 표현이다.
신설 §B (감지 장면) 4종:
- 08-넘어짐-감지-시퀀스: 상태기계를 그림으로. 1초(위험 자세) → 3초(확정)
→ 10초(미동 없음), ②에서 회복 시 니어미스 분기. 전체 최우선.
- 09-앉기-vs-넘어짐: 도입 검토 첫 질문("앉으면 울리냐")에 대한 답
- 10-위험구역-판정: 판정 기준이 몸통이 아니라 발 위치라는 점 + 0.75초
- 11-2인1조: 인원 수 기반 규칙 (30초)
수치·판정 조건은 edge/detectors/{fall,zone,occupancy}.py 실코드값으로
확인해 적었다. 그림이 제품과 어긋나면 영업 문서에 거짓을 싣는 것이 된다.
기존 B(다이어그램)→C, C(정리)→D, D(제외 목록)→E 로 재번호.
E의 '실제 프레임 제외' 항목에는 일러스트는 포함했다는 단서를 남겨,
나중에 판단이 뒤집힌 것으로 읽히지 않게 했다.
기존 소개서의 문제는 문장이 아니라 구조와 최신성이었다.
- 기대효과 섹션이 아예 없었다. 기능 목록만 있고 "그래서 뭐가 달라지나"가 없었다.
- 왜 만들었나가 4문장이었다. 이미 CCTV 가 있는 현장이 왜 또 사는지에 답하지 못했다.
- 내용이 0.9.0 시점이었다. 1.0.0 의 핵심인 통보·에스컬레이션이 로드맵에 "P3 예정"으로
적혀 있었고 테스트 320건·감사 43항목도 옛 수치였다 — 완성된 기능을 미완성으로 소개하는
문서가 고객에게 나갈 뻔했다.
재작성
- 기능 나열을 버리고 감지 → 대응 → 증거의 사슬로 재구성. 어느 한 칸이 비면 나머지가
무의미해진다는 것이 이 제품의 논지이므로 문서 구조도 그래야 한다.
- 왜 만들었나를 "CCTV 는 이미 있는데 왜 사고를 못 막나" 세 가지(아무도 안 본다 / 발견이
늦다 / 증명할 자료가 없다)로 세우고, 각각에 대응하는 답을 표로 연결.
- 기대효과 신설. 지어낸 ROI 대신 **제품이 스스로 계측해 보여주는 지표**(TTA, 니어미스:사고,
조치 전후 밀도, 오탐율)로 썼다 — 공급자 주장이 아니라 고객 화면의 숫자다.
'약속하지 않는 것' 절을 함께 넣었다. 사고를 없애지 못하고, 감지율 100%는 없고, 오탐은
초기에 반드시 난다. 과장은 도입 후에 반드시 되돌아온다.
- 설계 제약(감시하지 않는다 / 인터넷 없이 동작한다 / 클라우드를 안 쓴다)을 기능보다 앞에
두었다. 이 제품의 형태는 거기서 나왔다.
수치는 전부 코드와 대조해 교정
- 테스트 535건(pytest 311 + PHPUnit 224), 감사 55건, 감지 17종 + 서버 발행 1종
- "3초 안에 찾아낸다"는 과장이었다. 실제로는 쓰러진 자세가 3초 지속되면 확정이므로
"3초면 알아채고"로 고치고, 근거가 되는 3초를 감지 표에 함께 적었다.
이미지 브리프 신설 (docs/소개서-이미지-브리프.md)
- 현장 사진 4장 + 다이어그램 3장의 발주 명세. 각 항목에 "이 이미지가 없으면 전달 안 되는
것", 담을 것, 피할 것, 사양, 캡션안, 우선순위.
- 제품 화면 캡처 8장은 이미 확보되어 있어 발주 대상에서 제외.
- 개인정보 제약을 최상단에 명시. 얼굴이 식별되는 작업자 사진이 한 장이라도 들어가면
"감시하지 않는다"는 이 제품의 핵심 주장이 무너진다.
- 검토했으나 뺀 이미지(쓰러진 순간 프레임, 블러 전/후 비교, 스켈레톤 오버레이)와 그 이유도
기록. 나중에 "왜 없지"가 반복되지 않도록.
HTML 은 md-to-html-report 로 재생성(목차·표·콜아웃·인쇄 스타일). 실렌더 확인.
0.9.0 까지가 "감지하는 시스템"이었다면 1.0.0 은 "감지에 대응하는 시스템"이다. 그 차이를
만드는 것은 e00ad69 의 통보 경로이고, 이 커밋은 그것을 실제로 납품 가능한 형태로 만든다.
VERSION 0.9.0 -> 1.0.0
릴리스 규칙 위반 3건 수정 (rules/release-versioning.md)
- 산출물 파일명에 버전·날짜가 없었다. move-detect-cafe24.zip / 2_pi_install-<arch>.tar.gz
는 같은 날 두 번 빌드하면 구분이 안 되고, 현장이 무엇을 돌리는지 파일명으로 답할 수
없었다. 이제 move-detect-cafe24-<VERSION>-<YYYYMMDD>.zip /
move-detect-pi-<VERSION>-<YYYYMMDD>-<arch>-<abi>-<hailo>.tar.gz.
- 두 빌드 스크립트가 VERSION 파일이 없으면 "unknown" 으로 넘어갔다. 무버전 산출물은
빌드머신을 떠나는 순간 커밋과 대조할 방법이 사라진다 — 이제 exit 1 로 거부한다.
- deploy/README.md 의 stale 파일명 표기 갱신.
납품 동봉물 (skill delivery-audit: "설명서 + 점검 리포트 동봉")
- CHANGELOG.md 신설. 업그레이드 절차와 알려진 제약(모델 미확보 3종)을 명시.
- 역할별 사용 설명서를 패키지에 동봉. 운영자/안전관리자/시스템관리자 3역할 14절 + FAQ 6항,
실제 화면 캡처 8장을 base64 로 박은 단일 HTML(665KB). 폐쇄망에서 메일·USB 로 돌아다녀도
도판이 깨지지 않아야 하므로 단일 파일이다.
생성물은 커밋하지 않는다 — 소스(manual.json + shots/)만 두고 패키저가 매번 빌드한다.
커밋된 사본은 화면이 바뀐 뒤에도 최신처럼 보이고, 구 UI 캡처가 남은 매뉴얼은 납품물
자체의 결함이다.
- 감사 리포트를 '납품점검-리포트.md' 로 동봉. 무엇을 점검했고 무엇이 잔여인지 패키지가
스스로 답하지 못하면, 인수인계 몇 달 뒤 그 답을 아는 사람이 남지 않는다.
README-UPLOAD 에 크론 안내 신설 (누락이었다)
설치 안내서에 자동 실행 등록이 아예 없었다. 이 릴리스의 핵심인 통보가 크론 없이는 한 건도
나가지 않는데, 운영자가 읽는 유일한 문서가 그 사실을 말하지 않았다.
8번(알림 켜기: 크론 2개 + --dry-run 선행 + 경광등 우선) / 9번(설치 점검 /setup) 추가,
마지막 체크리스트에 3항목 추가.
부수 수정
- 셸 스크립트에 CRLF 가 섞여 bash 가 여는 중괄호에서 죽었다(빌드 자체가 불가). LF 로 정규화.
cafe24 스크립트에는 bash -n 가드가 이미 있어 이번에 실제로 잡았고, Pi 쪽에는 없어서
그대로 지나갈 뻔했다 — tests/test_deploy_artifacts.py 에 CRLF·bash -n 가드 추가.
산출물
dist/move-detect-cafe24-1.0.0-20260818.zip
dist/move-detect-pi-1.0.0-20260818-aarch64-cp311-hailo8.tar.gz
검증: pytest 통과(skip 3 = 하드웨어·환경) · PHPUnit 224 (skip 0).
매뉴얼 실렌더 확인(목차 정합·도판 8장·콜아웃). 패키지 내부 검증: 버전 스탬프 1.0.0,
마이그레이션 8/8 동봉, config.php 는 CHANGEME 템플릿, tools/ 는 www 밖에만,
웹 루트 PHP 는 index.php/_preflight.php 뿐.
e00ad69 로 늘어난 표면(라우트 17, 표 6종+컬럼 4, CLI 도구 5, 엣지 모듈 4, 통보 발송 경로,
감사 해시체인)을 8개 관점으로 재감사. 후보는 반증 시도 후 확정된 것만 채택.
CRITICAL
- A1 신규 감지 유형이 EventValidator 허용 목록에 없어 서버가 전부 400 으로 거부했다.
edge/queue.py 는 20회 재시도 후 그 행을 버리므로, 카메라 무결성 감지는 구현·테스트·배포가
끝난 상태로 end-to-end 무력화되어 있었다. DB CHECK 와 프런트 라벨은 갱신했는데 가운데
검증 계층만 빠진 형태 — 파이썬 테스트도 PHP 테스트도 각자 절반만 보기 때문에 둘 다 통과한다.
허용 목록을 고치고, 언어 경계를 넘는 회귀 테스트를 신설했다
(tests/test_event_type_parity.py: edge/events.py ↔ EventValidator ↔ schema.sql ↔
프런트 라벨 5종 ↔ 유형 필터). 실제로 A4 를 이 테스트가 잡았다.
- A2 seed_demo --purge 가 위험성평가를 reviewed_by='안전관리자' AND site='factory-a' 로
지웠다. 전자는 이 제품의 기본 관리자 계정명이고 후자는 흔한 현장명이다 — 운영 DB 에서
한 번 돌리면 실제 위험성평가가 복구 불가능하게 사라진다. 데모 행은 이제 전용 표식으로만
식별하고, 표식 없는 행은 어떤 경우에도 건드리지 않는다.
- A3 backup.php 의 웹 공개 경로 차단이 '/www/' 부분문자열이라 docroot 자체
(/home/acct/www)는 뒤에 슬래시가 없어 통과했다 — 가장 흔한 오사용이 정확히 그 경우다.
전 현장 사고 기록 + 작업자 이미지가 웹에서 내려받히고, 파일명은 하루 범위만 알면 추측된다.
경로를 세그먼트로 쪼개 웹 루트 이름(www/public_html/htdocs/html/web/public)을 거부한다.
HIGH
- A4 신규 유형이 app.js/detail.js/report.js 라벨 맵과 유형 필터에 없어 화면에 원시 코드가
그대로 떴고, 조사자가 그 유형만 걸러 볼 방법도 없었다.
- A5 서버 타임존 미설정. 대시보드는 브라우저 시각을 쓰지만 통보 메일은 PHP 가 만들고,
공유호스팅은 date.timezone 이 흔히 UTC 다 — 콘솔이 13:01 로 보여준 넘어짐을 메일이
04:01 로 적는다. 응급 대응에서 읽는 유일한 문장이 9시간 어긋나는 문제.
Config::timezone() 신설(기본 Asia/Seoul), 잘못된 값이면 폴백하되 발송은 막지 않는다.
- A6 ALTER TABLE … DROP CHECK 는 MySQL 8.0.16+ 전용이다. 카페24에 흔한 MariaDB 는
DROP CONSTRAINT 를 쓰므로 마이그레이션이 통째로 실패한다. 엔진별 분기를 명시하고,
이 CHECK 가 2차 방어선(1차는 EventValidator)임을 적어 실패해도 판단할 수 있게 했다.
MEDIUM
- A7 디스패처가 매분 창 전체를 재-claim 해 비용이 '새 이벤트'가 아니라 '창 길이'에 비례했다.
크론이 불안정해 창을 넓힌 현장일수록 나빠지는 방향이다. 원장을 한 번에 읽어 사전 필터하고,
claim() 은 경합 시 최종 권위로 남긴다.
- A8 move_alert_log 에 보존 정책이 없어 유일하게 무한 증가하는 표였다. 해소된 전달만
정리하고 failed 는 남긴다 — "아무에게도 닿지 않았다"가 조사에서 필요한 발견이다.
- A9 전역 관리자가 현장 채널을 수정하면 site 가 NULL 로 덮여 그 채널이 모든 현장에
발화했다. 폼에는 그런 안내가 없다. 수정 시 기존 site 보존.
- A10 일괄 확인이 대상마다 fetch 를 띄웠다. HTTP/2 는 직렬화하지 않으므로 수백 건이
공유호스팅에 한꺼번에 떨어진다. 동시 4개 제한 + 진행 중 목록 갱신.
LOW
- A11 패키지 테스트가 tools/ 배치를 검사하지 않았다. 이 도구들은 메일을 보내고 DB 전체를
읽으므로 www/ 아래로 새면 인증 없는 표면이 된다. CLI 가드 require·private 배치·웹 루트
PHP 화이트리스트 3종 검사 추가.
- A12 판정 배너와 CLAUDE.md 의 "43/43" 표기 갱신.
수정하지 않고 판단만 기록한 3건(웹훅 SSRF·서버가 지정하는 GPIO 핀·/setup 첫 카메라 한정)은
리포트 "확인만 하고 수정하지 않은 항목" 절 참조.
부수 효과: 기존 config 템플릿 가드가 timezone 키 누락을 잡아 두 템플릿도 함께 갱신됐다.
검증: PHPUnit 224 (skip 0, +3) · pytest 통과(skip 3 = 하드웨어·환경) · 산출물 패리티 통과.
backup 경로 가드는 docroot 자체/하위/비공개 3케이스로 회귀 확인. 감사로그 변조 탐지는
실제 UPDATE 후 verify_audit_chain.php exit 1 로 확인.
cafe24 번들 + Pi 오프라인 번들 재생성.
UI/UX 점검에서 시작해 "이 제품이 안전 시스템으로서 무엇이 없나"까지 내려간 작업.
두 덩어리를 한 커밋으로 둔다 — 뒤쪽 기능들이 앞쪽에서 고친 화면 위에 얹혀 있다.
━━ 1부: UI/UX 감사 21건 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
동선 — 확인(ack) 경로가 위험했다
- 스트림 정렬을 '미확인 우선'에서 엄격 시간순으로 되돌린다. 확인 상태가 연대기를
뒤집어 사고 재구성이 불가능했고, 확인하면 그 행이 목록 밖으로 사라지며 아래 행이
밀려 올라와 같은 좌표를 두 번 누르면 다른 이벤트가 확인 처리됐다. 이제 제자리에서
'확인됨'으로 전환되고 확정 토스트가 뜬다. 처리 큐는 정렬이 아니라 뷰모드 필터로 분리
(전환해도 행끼리 상대 순서는 절대 안 바뀐다).
- 실행취소는 넣지 않았다. acknowledged_by는 감사로그가 함께 남는 추가 전용 기록이라
무음 되돌리기가 안전 시스템에서 더 나쁜 속성이다. 대신 오탐·구역삭제를 2단계 확정으로.
- 이벤트 상세에 확인/오탐 신설. 판단하는 화면에 판단 도구가 없었다.
- 구역 설정에 장치·카메라 선택기. 시스템이 아는 값을 사람에게 묻고 있었고,
플레이스홀더(device-01)마저 실제와 다른 포맷을 가르쳤다.
- 날짜 조회(키셋 커서 시딩). 사고 조사 진입로가 아예 없어 '더 보기'를 반복해야 했다.
- 경보음 세션 음소거(새 위급 도착 시 자동 해제). 끌 수 없는 반복음은 안전 콘솔이
통째로 꺼지는 경로다.
전달 — 같은 단어가 화면마다 다른 숫자를 가리켰다
- KPI '미확인'(최근 200건)과 24시간 브리프 '미확인'이 40px 간격으로 충돌했다.
KPI에 실제 집계 범위를 명시. 중대=위급+높음을 인라인 정의.
- 페이지 명칭 3종·날짜 포맷 3종 통일. '확인 필요' 칩을 전 심각도로 확장
(그전엔 확인 여부가 먼 컬럼의 버튼 유무로만 구분됐다).
- 표본 게이트: 오탐율 '100% (1/1)'이 '33% (5/15)'보다 위에 뜨던 것, 최다 3건짜리
시간패턴 히트맵, 6→8건에 붙던 빨간 ▲33%. 개선조치 효과는 푸아송 잡음 범위(√before)를
넘어야 방향을 주장한다 — 안전회의에서 한 번 반박당하면 카드 전체가 신뢰를 잃는다.
- 카메라 위험 순위가 중대>0이면 전부 빨강이라 순위 노릇을 못 했다. 상대 기준으로.
시각 — 화면마다 완성도가 달랐다
- 구역 설정·이벤트 상세를 카드 셸로 재구성. 둘 다 스타일 없는 기본 폼이었고,
납품 심사에서 "덜 만든 티"는 정확히 거기서 났다.
- 히트맵 토글은 aria-pressed만 있고 CSS 규칙이 없어 켜졌는지 눈으로 알 수 없었다.
- 존 색을 팔레트와 정합(팔레트 어디에도 없던 보라 제거), 채움+라벨칩+협착 점선.
캔버스에 1px 외곽선만 그리면 실제 공장 프레임에서 사라진다.
━━ 2부: 안전 플랫폼 (마이그레이션 2026-08-09-safety-platform.sql) ━━━
통보 — 이 제품의 정의에 해당하는 구멍
지금까지 감지는 브라우저 탭이 열려 있고 사람이 보고 있어야만 전달됐다. 새벽 3시에
넘어진 작업자는 DB에 행 하나를 남기고 아침을 맞았다.
- 엣지 GPIO 경광등(edge/beacon.py). 인터넷·서버 없이 동작하는 유일한 경로라
어떤 현장이든 이것부터 설정한다. /api/config의 beacons로 배선을 받고, 하드웨어가
없거나 핀이 안 열리면 NullBeacon으로 떨어지되 그 사실은 반드시 로그로 남긴다.
- 메일·웹훅 채널 + 크론 디스패처(tools/dispatch_alerts.php). 중복 발송은 읽고-쓰기가
아니라 (event,channel) UNIQUE로 막는다 — 크론 중첩 실행은 공유호스팅의 상수다.
- 미확인 위급은 unacked_escalation 이벤트가 되어 상위 채널로 올라간다(event_uuid로 멱등).
'아무도 보지 않았다'는 사실 자체를 사건으로 만든다.
- 통보에 이미지는 싣지 않는다. 스냅샷은 PIPA 대상이고 메일링 리스트로 나가는 순간
인증 프록시가 지키던 통제를 벗어난다 — 링크만 보낸다.
조용한 실패 제거
- 카메라 화각 이동·가림·조도 저하 감지(detectors/integrity.py). 존 판정은 화각 고정을
전제로 서 있는데, 삼각대를 스치면 제한구역이 엉뚱한 바닥을 가리키면서 시스템은 계속
초록불이었다. numpy만 사용하고, 지나가는 사람과 구분하기 위해 지속시간을 요구한다.
- 스토리지 여유 감시. 디스크가 차면 이미지 저장만 조용히 실패하고 보드는 멀쩡해 보인다.
끊겨 있던 배선 2건
- thresholds_from_config()가 정의만 있고 어디서도 호출되지 않았다. 서버에 표가 있고
/api/config가 성실히 실어 보내고 엣지가 파싱한 뒤 버렸다 — 관리자가 무엇을 바꿔도
장비는 부팅 시 edge.env 값으로 영원히 돌았다(edge/tuning.py).
- 존·임계값 저장이 config_version을 올리지 않아 '설정 지연' 표시가 애초에 켜질 수
없었다. 지표는 있는데 그것을 켜는 사건이 없었다.
관리 화면 (그전에는 phpMyAdmin이 유일한 방법이었다)
- 임계값 편집: 자유 텍스트가 아니라 카탈로그(범위·단위·설명)로 렌더한다. 범위 밖 값은
엣지가 기동 시 거부하는데 그때는 이미 공장 안 장비가 재시작 루프다. 키보드 앞에서 막는다.
목록에 없는 키는 저장 불가 — 장비가 조용히 무시할 값이 '설정됨'으로 보이면 안 된다.
- 정비 억제: 사유 필수, 최대 8시간(한 교대), 자동 만료. 감지와 기록은 그대로 계속되고
통보만 보류되며 그 보류도 원장에 status='suppressed'로 남는다 — 조사에서 '경보가 났지만
의도적으로 withheld됐다'를 볼 수 있어야 한다.
- 교대 인수인계, 위험성평가, 설비 성능 추이, 설치 점검(/setup — 실제 상태 조회 기반).
증거·규정
- 감사로그 해시체인. 공유호스팅은 append-only 권한을 줄 수 없으니 '막는' 대신 '숨길 수
없게' 만든다. 수정·삭제·우회삽입이 전부 탐지되고(tools/verify_audit_chain.php, 손상 시
exit 1), 체인 이전 행은 손상이 아니라 '검증 불가 구간'으로 따로 센다 — 정상 시스템이
매번 빨간불을 켜면 아무도 이 도구를 안 본다.
- 사고조사 패키지 1클릭: 전후 타임라인 + 통보 이력(누구에게 닿았는가) + 위험요인 +
조치 이력 + 서명란. 인쇄 CSS로 그대로 제출 가능. 사고 다음 날 반나절을 쓸 사람은 없다.
- 이벤트→조치 직접 기록, 현장별 롤업(전역 계정만).
운영
- 일괄 확인(위급은 의도적으로 제외·2단계 확정), 키보드 트리아지(J/K/A/F/Enter),
모바일 레이아웃(44px 타깃), 보존기간 정리(tools/maintain.php),
백업/복구(tools/backup.php — www 안 출력은 거부), 데모 시드(tools/seed_demo.php).
- tools/는 전부 CLI 전용이고 납품 시 move_detect_app/tools/에 www 밖으로 배치된다.
미착수 4건 (모델·하드웨어가 선행 조건)
- 보호구·중장비 근접·화재 감지는 AGPL 아닌 가중치 확보 + HEF 컴파일이 관문이다.
감지기 배선·이벤트 계약·심각도·쿨다운·테스트까지 만들고 기본 비활성으로 둔다
(edge/detectors/pending.py). enabled=True인데 model=None이면 아무것도 하지 않도록
명시적으로 막았다 — "곧 됩니다" 상태로 켜 두면 현장은 감지되고 있다고 믿는다.
- PLC 접점 협착 게이팅도 같은 이유로 인터페이스만.
검증: pytest 통과(skip 3 = 하드웨어·환경) / PHPUnit 221 (skip 0, +59).
Chrome 실렌더로 신규 4화면 + 임계값 저장→config_version 증가→감사 기록→체인 검증→
행 변조→탐지(exit 1) 전 경로 확인. cafe24 번들 + Pi 오프라인 번들(aarch64 휠 11개)
재생성, 소스 패리티 통과. 보안 자체점검: SQL 문자열 보간 0, innerHTML 0,
상태 변경 핸들러 전부 CSRF+RBAC.
감사에서 확정된 잔여 항목을 일괄 처리. 이전 커밋이 증상(스테일 번들)을 고쳤다면 이번에는
그것이 조용히 지나가게 만든 구조를 고친다.
보안
- CSP 도입. 인라인 2블록에 per-request nonce, unsafe-inline 없음. 빌드가 없다는 제약이
nonce를 막지는 않는다. 보안 헤더를 .htaccess 단독에서 PHP로 옮겨 개발/납품이 같아진다.
- /logout에 CSRF. SameSite=Lax가 막아주지만, 사고 대응 중 운영자를 콘솔에서 튕겨내는 건
이 제품에선 실제 가용성 공격이라 예외로 둘 이유가 없다.
- 잠긴 계정 423 → 401. 아무나 이름을 추측해 5회 실패로 잠근 뒤 423을 계정 실재 확인으로
쓸 수 있었다. 잠금 사실은 감사 로그에 남고, 익명 호출자는 못 읽는다.
엣지
- 큐 flush에 회당 상한. 정전 복구 직후 2000건을 비우는 동안 감지가 멈추던 구간을 없앤다.
- 타이머 env 전수 검증. COOLDOWN_S=nan은 dedup을 무력화해 매 틱 발보시켰다.
- T2 프레임 스킵(INFER_EVERY). 스킵 틱은 프레임을 읽어 버퍼만 비우고 링버퍼에 넣지 않는다 —
추론이 없으면 얼굴 박스도 없어 그대로 넣으면 증거 스냅샷에 미블러 얼굴이 실린다.
T3(모션 게이트)는 의도적으로 미구현: 게이트가 틀리면 '움직이지 않는 쓰러진 작업자'에게
추론이 안 돈다. 실영상 없이 임계값을 정할 수 없다.
- systemd 워치독(sd_notify) + 메모리 상한. Restart=always는 죽은 프로세스만 잡고
'살아 있는데 멈춘' 상태는 못 잡는다. UVC 해상도·버퍼 고정(링버퍼 RSS가 웹캠 기본값에 좌우).
테스트 — 감사의 최대 갭
- PDO 통합 테스트가 pdo_sqlite 부재로 통째로 스킵되고 CI도 없어 사실상 한 번도 안 돌았다.
`-d extension=pdo_sqlite`로 되살리고 리포트·히트맵·익스포트 집계 SQL을 최초로 실행한다.
- 얼굴 블러 테스트가 out is not None만 봤다 — blur()가 무동작이어도 통과. PIPA 대응의
유일한 실행 가능한 증거였는데 아무것도 증명하지 못했다. 디테일 파괴를 단언하도록 교체.
- index.php 라우트 테이블 대조(프런트 호출 15개 교차검증), dist↔소스 패리티 테스트,
CI 신설(스킵이 하나라도 있으면 실패).
- ArraySessionStore.regenerate()가 데이터를 지워 실제 session_regenerate_id(true)와
달랐다. 더블이 실구현과 어긋난 자리 하나를 실제로 잡았다.
프런트
- 이벤트 목록 시그니처 재렌더 스킵 + 포커스 복원. aria-live 영역을 5초마다 통째로 다시
만들어 스크린리더가 200행을 재낭독하고 키보드 포커스가 body로 날아갔다. 같은 패턴이
device-tree.js에는 이미 있었다.
- 폴링 중첩 가드(느린 공유호스팅에서 응답 역전), 콜드스타트 실패 표시.
납품
- VERSION 신설, 두 산출물에 version/git_commit/built_at 스탬프. 스테일 번들의 SHA256SUMS는
자기정합적이라 통과한다 — provenance 없이는 하류에서 탐지할 방법이 없다.
- deploy/README·env.example을 Hailo 단독 번들 실제와 일치(템플릿이 없는 ONNX를 가리켰다).
- 이벤트 보존 정책은 기본 0(영구). 산업안전 기록을 시스템이 임의로 지우지 않는다.
검증: pytest 251 / PHPUnit 162, 스킵 0. CSP nonce 결합은 실제 렌더 경로로 확인.
브라우저 실렌더는 MySQL이 필요해 이 환경에서 미수행.
프로젝트 8개 차원 감사(종합 82/100)에서 확정된 상위 3건 수정.
1. 개선 조치(move_actions)가 유일하게 site 스코프 규율에서 이탈 — 스코프 운영자가
타 현장의 조치 기록(카메라 ID·메모·작성자)을 목록에서 읽고, 타 현장 카메라에
조치를 기록해 그 현장 리포트의 전후 효과 지표를 오염시킬 수 있었다.
- move_actions.site 컬럼 + (site, taken_at) 인덱스, 기존 행 백필 마이그레이션
- 카메라 소유 현장은 이벤트에서 해석 (move_cameras는 등록 플로우가 없어 항상 비어 있음)
- 오타 카메라는 400: 전후 효과가 영원히 0/0이 되는 조치를 애초에 못 만들게 한다
- 타 현장 카메라는 404: 존재 여부를 숨기는 기존 규약 유지
2. README-UPLOAD 업그레이드 절차가 마이그레이션 7종 중 1종만 지시했다. move_ 접두
리네임을 건너뛰면 이후 전부 실패하고 전 요청이 500이 된다 — 순서·적용조건·재실행
비안전성을 표로 명시. 문서 4곳의 테이블 수(10→11)·비프리픽스 표기도 정정.
3. 두 납품 번들 재생성: 8/6 이후 4개 기능 커밋이 반영되지 않아 Report/Export/Actions
컨트롤러와 8/7 마이그레이션이 빠진 채였다. 엣지 소스 패리티도 회복.
검증: pytest 235 passed / PHPUnit 147 passed (PDO 4 skip), 번들 산출물 내부 검증
(28 클래스 해석·전 파일 php -l·SHA256SUMS 55행·휠 11개·HEF 다이제스트 일치).
The operator's daily clicks become durable assets:
Label export (GET /api/export/labels?days=N, JSONL download):
- Every 확인/오탐 click is a human verdict on a detection, and each verdict
carries a blurred 3-frame evidence timeline. The export turns that into a
dataset: one JSON line per event with type/severity/camera/zone/metrics/
spatial, label (confirmed | false_positive | unreviewed), labeled_by, and
snapshot uuids (images fetched via /snap, which re-checks session + site).
ergo_report rows are excluded - statistics are not labelable detections.
- Admin-only ('manage_compliance'): bulk export of detection history is a
compliance decision, not an operator convenience. Site scope applies.
- This is where the deferred P1-C evaluation set will come from for free.
Improvement actions (move_actions + GET/POST /api/actions):
- Record a site fix against a camera ("미끄럼 방지 매트 설치"); the report's
new card compares detection density over EQUAL windows before/after the
fix (capped 30d; under 3 days shows "측정 중" - two days of data is noise,
not a verdict). Improvement shows accent, worsening shows red - the card
reports honestly in both directions, which is exactly what makes the
improvement numbers credible.
- Future timestamps and >1y-old entries are rejected (the comparison could
not be fair). Recording needs edit_zones + CSRF.
Verified: PHPUnit 145 pass (5 new: export perm gate + line shape/labels/
snapshot join, action validation, equal-window effect math, pending state)
+ Chrome real-render (action card with better/worse/pending rows, export
link, no console errors).
The accumulated data starts predicting instead of just recording:
- Time pattern (weekday x hour density grid): WHEN the site is dangerous, on
one card - shift-change, post-lunch, Thursday-afternoon hotspots become
visible. Bucketing runs in PHP on the server-local clock (site timezone)
so the SQL stays portable (the sqlite harness has no FROM_UNIXTIME).
- Precursor-spike alert: last-7-days count of precursor signals (near-miss,
zone entries, occupancy violations - signals, not injuries) vs the prior
4-week weekly baseline. Ratio >= 1.5 with at least 5 samples raises a
banner above the KPIs: an alarm on the PRECURSOR of an accident, with a
concrete suggestion (staffing/route/rest review). Below the sample floor
it never fires - a ratio over 2 events is noise, not statistics.
- Period-over-period trends on every KPI: reportSummary gained an optional
upper bound so the identical aggregation runs over the preceding window.
Worsening turns red, improving turns accent - except the near-miss count,
which stays NEUTRAL: more near-misses may mean better detection, not more
danger, and the report must not editorialize that.
Verified: PHPUnit 140 pass (2 new: window separation + pattern bucketing,
spike ratio + sample floor) + Chrome real-render (alert banner, KPI trend
lines, Thursday-afternoon hotspot readable in the grid).
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).
The benefit made visible: /report folds everything the system collects into
the one printable page a 안전관리자 takes into the weekly safety meeting.
- GET /api/report?days=N (7/30/90): severity totals, per-type counts,
worst-camera ranking, response time (TTA - acknowledged critical/high only,
FP-tagged rows excluded: dismissing a false alarm is not an emergency
response; clock-skew guarded), the near-miss pyramid (near_fall vs
fall/no_motion - the Heinrich argument in one ratio), FP rate, summed
posture-load exposure, occupancy-rule violation counts. ergo_report rows
feed the sums but are excluded from "감지 N건" (bookkeeping, not detection).
Fail-closed site scoping throughout.
- /report view: KPI band (중대 이벤트 / 평균 대응 / 니어미스:사고 / 오탐율,
each with its one-line reading), bar lists for types and cameras (critical
presence highlighted), ergo bands as share-of-observed-time, rule tiles.
Print CSS strips the app chrome - the 인쇄 button produces the meeting
handout. New 리포트 nav tab.
- Events console: event-type filter beside severity - the INFO-volume types
(near_fall, ergo_report) can now be folded out of the live stream, or a
single type isolated for investigation.
Verified: PHPUnit 138 pass (3 new: aggregation math incl. TTA/FP-exclusion/
ergo sums, site scope, auth) + Chrome real-render of the full report (KPIs,
bar lists, rule tiles, footer) and the type filter isolating one type.
- cafe24 package now stages server/migrations/ beside schema.sql: schema.sql is
for a FRESH database only, an already-imported host upgrades by running the
migrations in date order (then deletes them, like schema.sql).
- Rebuilt dist/1_cafe24_upload (+ move-detect-cafe24.zip): autoloader resolve
check 28 classes, parse check, all of today''s controllers/assets/schema in.
- Rebuilt dist/2_pi_install with --cross: aarch64/cp311 wheelhouse (11 wheels,
onnxruntime included), HEF verified against the pinned hailo8 digest,
requirements.lock + sbom.json + SHA256SUMS regenerated, ergonomics detector
staged.
- FLAG: the MoveNet ONNX upstream URL 404s, so this Pi bundle is Hailo-only
(no CPU-fallback pose model). fetch_movenet.py needs a new source URL before
a delivery that requires the CPU path.
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).
The feedback loop, per product decision (option a): tags feed STATISTICS only.
A human reads the rate and re-tunes a camera deliberately - the tag never
adjusts edge sensitivity on its own (no silent behavior change in a safety
system).
- POST /events/{id}/false-positive: same RBAC ('acknowledge') + CSRF + site
scope as ack. Tagging also acknowledges (an FP is a handled event - leaving
it unacknowledged would keep it alarming). COALESCE keeps the FIRST tagger:
a second tag never rewrites who made the original audit-relevant call.
- move_events.false_positive_by/at (migration included), audit-logged.
- GET /api/fp-stats?days=N: per-(camera, event_type) totals + FP counts,
FP>0 groups only, fp-desc, fail-closed site scoping.
- Console: each open row gets a visually-secondary 오탐 button beside 확인
(can't be fat-fingered as the default); resolved rows show a mono verdict
("오탐 (user)" struck through vs "확인됨 (user)"). New rail panel "오탐율 ·
30일" lists top offenders - 30%+ highlighted as re-tuning candidates -
refreshed on a slow 60s cadence plus immediately after a tag. Detail view
shows the tag.
Verified: PHPUnit 135 pass / pytest unchanged + Chrome real-render (tag ->
row flips to struck-through verdict + drops out of unacked KPI/tree counts ->
FP panel updates immediately; no console errors).
Heinrich's-law play: the signals we already compute but used to throw away
become a spatial risk map of the workplace, drawn over the camera's own frame.
Edge:
- near_fall (INFO): a track that reached FALLING (sustained prone) but recovered
before the FALLEN confirm. Treated as a full event per product decision -
same ingest, snapshots, dedup, ack flow as incidents. Fix en route: prone_s
used a truthiness check that turned prone_since=0.0 into "no timer".
- Every per-track event (fall/no_motion/near_fall/immobility/zone hits) now
carries the foot point in the zones' normalized 0..1 space (reusing the zone
detector's own coordinate math); ghost's pixel-space loss point is normalized
at emit. to_payload omits the key when absent, so an old server ignores it.
Server:
- move_events.spatial_x/spatial_y (nullable DOUBLE) + validator: optional
[x, y] finite-number pair; range deliberately NOT enforced (an off-by-a-hair
foot must never reject a FALL alert - renderers clamp).
- MIGRATION FIX: chk_events_type would have REJECTED the previous commit's
lone_worker/overcapacity/overstay inserts on MySQL 8.0.16+ - the constraint
is rebuilt with all 13 types (incl. near_fall).
- GET /api/heatmap/{camera}?days=N (1..90, default 30, 5000-point cap):
spatial points with the same fail-closed site scoping as the event list.
UI (zone editor): "위험 히트맵" toggle + day window. Additive radial blots in
an offscreen layer under the zone polygons; severity weighs the alpha so a
cluster of near-misses reads lighter than one confirmed fall at the same spot.
Events console/detail label near_fall in Korean.
Verified: pytest 222 pass / PHPUnit 130 pass + Chrome real-render (blue
near-miss cluster + red incident hotspot inside the restricted zone, amber
cluster in the warning zone, zones drawn on top; no console errors).
Per-zone declarative safety rules, set in the zone editor and enforced on the
edge from the tracking the pipeline already does (no new geometry, no new model):
- min_workers (2인 1조): 0 < count < minimum sustained 30s -> lone_worker/HIGH.
A solo worker in a buddy-rule zone is a compliance violation and a rescue-
delay risk, so it outranks the other two.
- max_workers: count > limit sustained 10s -> overcapacity/WARNING.
- max_dwell_s: one track's continuous stay past the limit -> overstay/WARNING.
A short detection dropout (<=5s grace) does NOT reset the dwell timer -
without that, run_once's stale-track filter would quietly neuter every
10-minute rule at real-world detection rates.
Edge: detectors/occupancy.py consumes Zone.rules; the engine flips the zone
detector's per-track membership into per-zone headcounts (stale tracks already
excluded), so counts always match what was actually detected this tick. Fired
states clear on condition recovery - re-alerts need a fresh violation, not a
cooldown expiry. Malformed rules_json keeps the zone's intrusion detection
alive and logs the rules as inactive (never fail dark).
Server: move_zones.rules_json (nullable JSON, migration included) validated
strictly in ZoneConfigController (bounds, min<=max, unknown keys rejected -
a silently dropped rule is one the operator believes is active). Delivered to
the edge unchanged via GET /api/config. EventValidator accepts the 3 new types.
UI: zone editor gains the three rule fields (dwell entered in minutes, wired
in seconds) and shows each zone's rules in its list row; the events console
and detail view now render every event type in Korean (wire codes fall back).
Verified: pytest 216 pass / PHPUnit 123 pass + real-render harness in Chrome
(editor save payload, list labels, console type labels).
- 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.
- Rail rework: the flat device strip + camera <select> becomes one device->camera
tree (device-tree.js owns the DOM). A device row carries the heartbeat verdict
(online/offline, fps, temp, throttle, config drift); the cameras seen in the
event stream nest under it and double as the stream's camera filter.
- The camera filter is a (device, camera) PAIR: camera ids are free-form edge
strings, so two devices may both run a "cam-01" - one device's events no longer
leak into another's filtered view. Clicking the selected camera (or 전체 보기)
clears the filter.
- A camera whose device is missing from /api/devices renders as its own
unknown-device group ("장치 목록에 없음 - 등록 확인") instead of being dropped.
- Offline rows show liveness only - fps/temp are the LAST values a dead device
reported and would read as current telemetry.
- Poll-failure staleness, collapse state, and focus survive the 5s re-render;
an unchanged poll never rebuilds the DOM (render signature).
- Stream rows: device NAME + mono camera id + compact 24h stamp (year only when
not current); thumb slot links to the incident and is honest about 무영상 for
screen readers; KPI strip captioned "미확인" once.
Verified: PHPUnit 119 pass (4 skipped, PDO gate) + real-render harness in Chrome
(tree states, pair filter, collapse, reset, no console errors).
Rework the events dashboard from the sparse light-minimal look (which read as
empty / unprofessional) into a professional, information-dense operations
console — keeping the LIGHT direction (the dark HMI was previously rejected).
- App shell: top bar with an "AI SAFETY CCTV" brand mark + product identity, and a
device/camera rail (a second neutral layer) beside the detection stream.
- Density: device health + filters move into the rail; the four hero KPI cards
become a compact inline pill strip; the stream is snapshot-forward.
- Every event row is a camera slot (evidence thumbnail, or a "무영상" placeholder)
so the stream reads as a CCTV grid and rows stay aligned regardless of snapshots.
- Restrained near-monochrome; indigo for interaction only; severity colour spent on
the unacknowledged-critical alarm only; contrast-checked ink; reduced-motion honoured.
- Pure CSS, system fonts, zero external assets (air-gap). DOM contract (ids/classes)
unchanged, so polling/ack/alarm/filter logic is untouched. Verified in-browser.
- Heartbeat reports the CONFIGURED camera count, not just the started ones: a
camera that failed to construct at boot (wrong index / unseated ribbon) now
shows as total/erroring instead of vanishing, so the server can see it is dark.
- GHOST emits without an engine cooldown key (ghost.update already fires once per
never-reused track id) — removes a permanent _last_emit key that forget() could
never reclaim (a slow 24/7 leak).
- Malformed zone rows are logged loudly instead of silently swallowed, matching
the pixel-space branch's "don't fail dark" — a dead zone is no longer discovered
only after a worker enters it.
- server-deploy.md: state MySQL 8.0.13+ / MariaDB 10.2+ is required (expression
defaults), not "5.7+" — an import against 5.7 is a day-1 ERROR 1064 blocker.
- pilot-runbook: add the air-gapped Pi path and mark pip/fetch as build-machine
only; stop the shipped doc from telling a Pi operator the bundle "does not exist".
- install_offline.sh: instruct the camera probe before enable (mirrors setup_online).
- setup_online.sh: chmod 600 device.env on read; README documents delete + rotate.
- systemd unit: order After/Wants=time-sync.target so occurred_at is real wall time.
- Device-status panel (DeviceStatusController): site-scoped liveness over stored
heartbeat telemetry. Fix config_drift false-positive by storing NULL until the
device actually reports a version (was firing "설정 미적용" on every device).
- Zone save rejects a blank/unknown device_id (DeviceRepository::exists) so a zone
is never persisted against a device that will never fetch it.
- Snapshot proxy sends immutable private cache headers, ending the 5s-poll
re-download of every thumbnail (a Cafe24 traffic-cap hazard).
- Event list: paged-in acknowledgements stick; honesty affordances for the paging
gap, a stale device poll, and a filtered camera aging out; logout wired for
every page via the shared layout.
- 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).
Drawing hazard zones on a blank canvas meant guessing pixel coordinates.
The editor can now pull the camera's most recent snapshot and draw over
what the camera actually sees.
- SnapshotRepository::latestForCamera() (+ Pdo/in-memory impls), ordered by
snapshot_id, not the device-reported occurred_at (a skewed Pi RTC would
otherwise pin 'latest' to a stale frame)
- GET /api/cameras/{camera}/latest-snapshot -> {uuid}; returns only the id,
the bytes still go through SnapshotProxy's session + site-scope check.
Out-of-scope and missing both 404, so a scoped operator cannot probe for
another site's cameras
- ix_events_camera (camera_id, event_id) so the lookup is not a full scan
- '현재 화면 불러오기' button in the zone editor
Tests: PHP 113 pass (4 skipped, 4 new), pytest 174 pass.
build_runtimes() logs-and-skips a camera it cannot open, so a wrong
/dev/video index or an unseated ribbon produced a green systemd unit
that silently monitored one camera less.
- edge/probe.py: opens each camera in CAMERAS, retries while the sensor
settles, reports frame geometry, optionally saves preview PNGs, and
exits non-zero with a backend-specific hint (v4l2-ctl / rpicam-hello)
- setup_online.sh: new step [8/9] runs it against the installed package
and the CAMERAS line actually written to edge.env, and refuses to
enable the unit on failure. SKIP_CAMERA_CHECK=1 opts out.
Tests: 174 pass, 2 skipped (7 new).
The online Pi install required a four-variable command line typed on the
device. setup_online.sh now reads DEVICE_ID / DEVICE_SECRET / CAFE24_URL /
CAMERAS from deploy/device.env (written on the build machine) so the whole
install is 'sudo deploy/setup_online.sh'.
Real env vars still take precedence, so the documented long-form command
overrides a stale device.env. device.env is gitignored -- it carries the
device HMAC secret in plaintext.
Delivery had two ambiguously-named outputs and always produced an
archive, which made it unclear what actually gets uploaded/copied.
- dist/1_cafe24_upload/ -> FTP-upload contents to the Cafe24 web host
- dist/2_pi_install/ -> copy folder to the Pi, run ./install_offline.sh
Archives are now opt-in: MAKE_ZIP=1 (Cafe24) / MAKE_TARBALL=1 (Pi).
Docs and tests updated to the new paths.
Tests: 165 pass, 2 skipped.
The PHP app runs on Cafe24 shared web hosting -- calling it 'cloud'
was misleading. Renames the directory, the edge client module, and
every doc/script/identifier that used the term.
- cloud/ -> server/ (incl. server/deploy/build_cafe24_package.sh paths)
- edge/cloudclient.py -> edge/serverclient.py, CloudClient -> ServerClient
- cloud/docs/cloud-deploy.md -> server/docs/server-deploy.md
- CLOUD_* env/config keys -> SERVER_*
- tests renamed to match
Tests: PHP 109 pass (4 skipped), pytest 167 pass. dist/ regenerated.
Renames the 10 P1-B tables (users, devices, cameras, zones, events,
event_snapshots, thresholds, nonces, rate_limits, audit_log) to a
move_ prefix so the schema can co-tenant a shared-hosting database.
- schema.sql + all App\Repository\Pdo\* queries + tests + deploy docs
- new migrations/2026-07-20-move-table-prefix.sql: atomic RENAME TABLE
for existing installs; must run BEFORE deploying this code
- index/constraint names left unchanged (no cross-table collision)
Tests: 109 pass, 4 skipped.
On a Windows/Git-Bash build box with no `zip`, the packager fell back to
PowerShell Compress-Archive, which writes BACKSLASH path separators. Cafe24's
extractor (and some unzip tools) do not normalise them, so the app tree unpacked
as flat files (move_detect_app\src\... as a single filename) and the deploy
silently broke — a likely cause of the repeated install failures.
Prefer a Python zipfile step (POSIX arcnames) between `zip` and PowerShell.
Verified: rebuilt zip extracts with a correct move_detect_app/src/... tree and
no "backslashes as path separators" warning.
Two independent adversarial reviews (codex CLI + a 6-dimension verification
workflow) of the delivery-hardening branch surfaced ~40 findings; this closes
the confirmed correctness/security/deploy blockers plus a codex re-review pass.
edge:
- geometry: track_displacement per-second threshold recalibrated (0.02->0.25,
env-tunable via MOTION_THR, validated finite/positive); confidence-filter
keypoints with a bbox-center fallback so hallucinated points don't fake motion
- fall: low-confidence frames no longer cancel an active FALLING/FALLEN incident
(freeze + reset recovery timer so recovery needs a fresh confident streak);
baseline height only seeded from trusted frames
- queue: PostClient.send() returns a tri-state SendResult; only definite 4xx
count toward the poison drop cap, so a network outage (incl. 408/425/429/5xx)
never deletes queued safety events; legacy rows persist their minted uuid
- config: reject placeholder / http / short DEVICE_SECRET at startup
- zones: warn+skip legacy pixel-space polygons that would silently match nothing
cloud:
- zone endpoints wired into P3-2b site-scope (cross-site read/write/delete
closed) + role gate on list; catch narrowed so DB faults surface as 500
- snapshot purge keeps the DB row when unlink() fails (no untrackable PIPA orphan)
- ingest dedup path re-stores missing snapshots (+ short-write guard); UNIQUE
(event_id, content_hash) makes recovery idempotent under retry/race
- event list cursor carries full-precision occurred_at (no float rounding at the
page boundary); string keyset end-to-end
- SiteScope '' fails closed (only NULL is global); dashboard fetches full page cap
- runnable migration (site/event_uuid + backfill ordering + indexes); rate-limit
reap fails open
deploy:
- offline bundle hashes EVERY file (fatal on missing/failed/injected; models in
SBOM; arch in tarball name; REQUIRE_SIGNATURE option)
- installer verifies before any mutation, Hailo arch preflight before install,
atomic src swap, backend reconcile preserves a working operator choice
- systemd DeviceAllow=char-dma_heap (the /dev/dma_heap/ dir form was inert)
Verified: edge pytest 165 passed / 2 skipped; cloud phpunit 109 passed / 4
skipped; deploy scripts bash -n clean. Reviewed by codex twice (findings +
fixes) with the second-pass findings folded in.
Two approved data-model changes.
P1-9 — ingest idempotency. A POST can succeed server-side but time out
client-side; the edge then re-queues and re-sends the same event, so the
cloud stored duplicate alerts. The edge Event now carries a stable
event_uuid (uuid4 hex) generated once at creation and preserved through
the offline queue and every retry. Cloud: events.event_uuid CHAR(32) NULL
UNIQUE; EventValidator accepts/validates it; IngestController returns the
already-stored event (200, dedup:true) on a repeat uuid, race-safe against
the UNIQUE key (catches the insert-race PDOException and returns the
winner).
P3-2b — site-scoped access control. site_scope was stored in the session
but never enforced, so any authenticated user could read every site's
worker snapshots (PIPA). Added devices.site; ingest denormalizes it onto
events.site (server-derived from the authenticated device_id, never
client-supplied). App\Security\SiteScope centralizes the rule (NULL scope
= global operator). The event list filters in SQL (page(...,$site)); event
detail + acknowledge and the snapshot proxy guard per-row and return 404
(not 403) cross-site so existence never leaks.
Schema migration ALTERs and per-device/user provisioning UPDATEs are
documented at the bottom of schema.sql.
Tests: edge queue round-trip keeps the uuid; repeat-uuid dedups to one
row; list scoped to caller site; detail cross-site 404; snapshot
cross-site 404 / same-site 200; event_uuid validation. phpunit 97 passing
(4 skipped: PDO), pytest 158 passing (2 skipped).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
Records the pass that closed P4-5/6/7/8, P5-3/4, P2-9/10/11 and documents
why the remaining 8 are not solo-doable (data-model decisions: P1-9,
P3-2b; on-hardware safety-timing validation: P2-6/7/8).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
Three keypoint-trust hardening fixes that reuse the codebase's existing
"visible" confidence convention (no new tuning knobs):
- P2-9 zone.py: _foot averaged both ankles unconditionally, so a
hallucinated ankle (conf~0, often pinned to (0,0)) dragged the foot
point into or out of a zone. Now averages only ankles with conf >= 0.2
and falls back to the bbox bottom-centre when neither is trustworthy.
- P2-11 geometry.py: head_below_hips used the NOSE regardless of
confidence. Now returns False when the nose is below the visibility
threshold, so an unconfident/hallucinated nose can't fire the
head-below-hips fall cue. The confident-nose path is unchanged
(test_fall.py stays green).
- P2-10 movenet_pose.py: the CPU MoveNet fallback is single-person, so at
a multi-worker site every worker but one is silently invisible to
fall/zone/ghost detection. Construction now logs a loud startup WARNING
pointing to the Hailo backend.
Tests: test_zone (bbox fallback), test_geometry (low-conf nose), and
test_movenet_pose (caplog warning). Full python suite: 157 passed, 2
skipped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
The offline bundle shipped unpinned pip deps with no digests, no SBOM,
and no signature — an air-gap target can't re-resolve from PyPI, so a
truncated or tampered wheel would install silently.
build_offline_bundle.sh now emits, from the resolved wheelhouse:
- SHA256SUMS — sha256 of every wheel + model
- requirements.lock — exact name==version pins for a reproducible rebuild
- sbom.json — CycloneDX 1.5 components (name, version, pypi purl, sha256)
- SHA256SUMS.asc — optional detached GPG signature when SIGN_KEY is set
MANIFEST advertises the integrity set and whether it is signed.
install_offline.sh verifies `sha256sum -c SHA256SUMS` (and the signature,
if present) BEFORE any pip install, hard-failing on mismatch; older
bundles without the file pass with a warning.
The generation logic was verified end-to-end on a synthetic stage:
requirements.lock/SHA256SUMS correct, `sha256sum -c` passes, sbom.json is
valid CycloneDX JSON. Both scripts pass `bash -n`.
Tests (test_deploy_artifacts.py): pinned integrity manifest is emitted
(+ optional SIGN_KEY), installer verifies integrity before install.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
The air-gapped bundle never fetched the Hailo HEF, so an offline Hailo
install had no pose model. Two latent breaks also made the offline path
unusable regardless of P5-3:
- build [2/5] ran `fetch_movenet` unconditionally; with the MoveNet
source unreachable that aborted the whole build under `set -e`.
- install_offline copied only `*.onnx`, so even a HEF placed in the
bundle never reached $PREFIX/models for the backend gate to find.
Fixes:
- build_offline_bundle.sh fetches the $HAILO_ARCH (default hailo8) HEF
via edge.tools.fetch_hailo_hef (pinned sha256, aborts on mismatch) and
stages yolov8s_pose.hef. MoveNet ONNX is now optional (guarded fetch →
Hailo-only bundle if unavailable). onnxruntime moves to a best-effort
optional wheel. MANIFEST lists models + available pose_backends.
- install_offline.sh copies *.hef (and tolerates a missing *.onnx).
Tests (test_deploy_artifacts.py): bundle fetches+stages the HEF, MoveNet
is optional (guarded), installer copies the HEF. Both scripts pass
`bash -n`. Full python suite: 152 passed, 2 skipped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW