move_detect/docs/delivery-audit.md
유창욱 6b6f29c447 feat: 납품 잔여 3건 해소 (Pi 번들 · MoveNet 폴백 · 자격증명 회전)
Pi 번들 — arm64 빌드 머신이 필요하다고 적어 둔 것은 과한 가정이었다.
--cross 모드에서 pip 이 태그로 aarch64/cp311 휠을 받으므로 네트워크만
있으면 된다. 휠 11개 전부 aarch64. "Pi 번들 미빌드"로 스킵되던 테스트가
이제 실제로 돈다.

MoveNet — P3-3 의 잔여였던 죽은 원본을 유효한 tf2onnx 변환본으로 교체하고
sha256+size 로 고정했다. 고정 전에 어댑터 계약과 대조했다: 입력
[1,192,192,3] int32 NHWC, 출력 [1,1,17,3]. 레이아웃이 다른 변환본은
onnxruntime 에 그냥 로드되고 좌표만 틀리는 실패라, 파일이 바뀌었는지가
아니라 계약이 맞는지를 테스트로 박았다.

자격증명 회전 — 손으로 적던 SQL 절차를 명령 하나로 바꿨다. DB 에 접속하지
않고 붙여넣을 SQL 만 낸다(운영자의 창구가 phpMyAdmin 이므로 접속 정보 없이
어느 머신에서나 돌아야 한다). 비밀번호는 STDIN 으로만 받는다 — 명령행은
ps 와 셸 히스토리에 남는다. 테스트가 잠그는 것은 서버 키와 Pi 키의 일치,
그리고 앱이 그 해시를 검증하는지 두 가지다. 회전 실행 자체는 운영 계정
조작이라 저장소에서 수행하지 않는다.

검증: phpunit 276 passed(스킵 0, 신규 7) · pytest 스킵 2(환경)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBjuR7DW6PNNMows4KnbYU
2026-09-21 10:47:16 +09:00

587 lines
No EOL
46 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Delivery Audit — move-detect (납품 전 결함 목록 · 순차 처리)
> **판정 (2026-09-21, 1.2.0): 적합 — 오프하드웨어 항목 전량 해소.**
> 1차 43/43 + 2차 12/12 + 3차(1.1.0 장치 건전성) 14/14 +
> **4차 재감사(1.2.0 추락·동시 다발·프리셋 표면) 23/23** 해소.
> 2026-09-21 납품 정비로 Pi 번들 재빌드·MoveNet 원본·자격증명 회전 도구까지 닫혔다.
> 잔여는 실장비(OFF-HW) 검증과 **운영자가 직접 해야 하는 자격증명 교체** 뿐.
Source: 62-agent adversarial audit (2026-07-16) + skill `iot-airgap-delivery` §3 + manual
verification of every blocker against real code. Ordered for sequential fixing. Each item is
checked off only after a test locks it and verification passes.
Legend: **[B]** blocks delivery · **[H]** high · **[M]** medium · **[L]** low ·
`OFF-HW` = cannot be verified without the Hailo board.
---
## Phase 1 — Silent failure & misconfiguration (the #1 delivery risk)
A safety system that *looks* healthy while doing nothing is the worst failure. Make every
misconfig/degradation fail LOUD.
- [x] **P1-1 [H]** `edge/serverclient.py`, `edge/alert.py` — `requests` sends the default
`python-requests/x.y` User-Agent; cafe24's shared-hosting WAF commonly blocks it with an
HTML 403 while curl passes. Every event then silently fails. Also `allow_redirects=True`
turns the .htaccess http→https 302 into a GET → 405/404. **Fix:** custom UA
(`move-detect-edge/1.0`) on every call + `allow_redirects=False`, log once on 3xx.
DONE — `USER_AGENT` in alert.py, both clients send it + `allow_redirects=False`;
tests in test_alert.py / test_serverclient.py.
- [x] **P1-2 [B]** `deploy/systemd/move-detect-edge.env.example`, `setup_online.sh` prompt,
docs — `CAFE24_URL` must be `https://<domain>/move_detect` (subdir mount). Empty/typo/
missing-subdir is 100% silent. **Fix:** examples show the subdir; installer validates.
DONE — env.example + installer prompt now show/require the `/move_detect` subdir.
- [x] **P1-3 [H]** `edge/config.py:40` + `edge/daemon.py` — empty `CAFE24_URL`/`DEVICE_SECRET`
defaults let the daemon run green while every upload fails and the queue grows forever.
**Fix:** fail loud at startup if either is blank.
DONE — `require_upload_config(cfg)` raises at daemon startup; test_config.py locks it.
- [x] **P1-4 [H]** `edge/daemon.py:23` — `run_loop` swallows every per-frame/per-camera
exception with no log and no degraded-monitoring signal → a permanently broken pipeline
looks green. **Fix:** rate-limited `logging.exception` + consecutive-error counter.
DONE — per-camera + periodic error streaks, logged 1st + every 300th, names the camera;
test_daemon.py locks the rate-limit.
- [x] **P1-5 [H]** `edge/daemon.py:33` — heartbeat hardcodes `fps=0.0` and counts ticks where
`source.read()` returned nothing, so the server cannot tell a dead device from a live one.
**Fix:** real fps + per-camera frame-age/health in the heartbeat body.
DONE — heartbeat now sends measured fps + cameras_total/cameras_erroring/errors;
test_daemon.py locks fps math + failing-camera flag.
- [x] **P1-6 [H]** `edge/adapters/uvc_source.py:12` — a camera that stops delivering frames is
never detected or reopened; monitoring dies silently. **Fix:** fail loud on open failure;
emit `device_fault`/`monitoring_degraded` when a camera goes stale.
DONE — open-failure raises (build_runtimes logs+skips); read() releases+reopens after a
streak; heartbeat's cameras_erroring (P1-5) reports the fault. test_uvc_source.py locks it.
- [x] **P1-7 [M]** `edge/queue.py` — offline sqlite queue has no size cap/TTL; an outage (or a
permanent misconfig) fills the SD card, then new events drop silently. **Fix:** bounded
queue (row/byte budget), log dropped-oldest count.
DONE — `max_rows` (default 2000) evicts oldest on enqueue; test_queue.py locks it.
- [x] **P1-8 [H]** `edge/queue.py:52` — a permanently-rejected event (e.g. 400) is retried
forever at the head, blocking the whole queue. **Fix:** drop/quarantine after N failures.
DONE — `attempts` column; a row is dropped after `max_send_attempts` (default 20) so a
poison head unwedges the queue; test locks the drain-past-poison behavior.
- [x] **P1-9 [M]** `edge/alert.py:43` — a POST that succeeds server-side but times out
client-side is re-queued and re-sent; no dedup key → duplicate alerts. **Fix:** stable
client-generated `event_id` in the payload; server dedups on it.
DONE — the edge `Event` now carries a stable `event_uuid` (uuid4 hex, generated once at
creation, preserved through the offline queue + every retry). Server: `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.
Tests: queue round-trip keeps the uuid (edge); repeat-uuid dedups to one row (server).
- [x] **P1-10 [M]** `edge/daemon.py:134` — `TARGET_FPS=0`/non-numeric → ZeroDivision/ValueError
at every start → infinite systemd restart loop. **Fix:** validate, default sanely.
DONE — `frame_interval_from_env` validates; test_config.py locks it.
- [x] **P1-11 [L]** `edge/daemon.py:120` — `QUEUE_DB` defaults to relative `queue.db`,
unwritable under the hardened unit (`ProtectSystem=strict`) → crash loop when env omitted.
**Fix:** absolute default under the state dir.
DONE — `queue_db_path` defaults to /var/lib/move-detect/queue.db; test_config.py locks it.
## Phase 2 — Core safety correctness (fall / zone / tracking)
- [x] **P2-1 [B]** `edge/zones.py:19` ↔ `server/public/assets/zone-draw.js:196` — zones are
saved in 960×540 canvas pixels but the edge compares against camera-native pixels; **every
zone is silently misplaced.** **Fix:** normalized 0..1 polygon contract on both sides.
DONE — dashboard stores/loads 0..1 (zone-draw.js), edge normalizes the foot by frame size
(zone.py → engine.process → run_once threads frame_wh); Polygon::validate is range-agnostic
so PHP stores it opaquely. test_zone.py locks the normalized contract.
- [x] **P2-2 [B]** `edge/engine.py:72` — a `pinch` (협착) zone falls through to
`ZONE_WARNING`; the PINCH event + HIGH severity are never emitted. **Fix:** map
`pinch→PINCH`, `restricted→ZONE_RESTRICTED`, else WARNING.
DONE — dict map pinch→PINCH/restricted→ZONE_RESTRICTED; test_engine.py locks PINCH+HIGH.
- [x] **P2-3 [H]** `edge/detectors/fall.py:65` — a per-frame keypoint-confidence gate
silently disables fall, no_motion AND ghost for an occluded/low-confidence fallen person —
exactly when someone is on the floor. **Fix:** use bbox cues when keypoints are low-conf.
DONE — bbox cues (aspect/height-collapse) always run; keypoint cues gated by kp_ok;
test_fall.py locks both the low-conf detection and the no-false-positive-standing case.
- [x] **P2-4 [H]** `edge/detectors/ghost.py:38` — a ghost candidate is cancelled when ANY
person (a colleague) passes near the loss point. **Fix:** only the plausibly-same track
cancels it.
DONE — candidate snapshots the active track ids at loss time; only a NEW track (not a
known bystander) near the loss point cancels. test_ghost.py locks the bystander case.
- [x] **P2-5 [H]** `edge/geometry.py:66` — stillness compares raw per-frame displacement to a
fixed threshold with no dt normalization → immobility/no_motion flips with FPS. **Fix:**
normalize by elapsed time.
DONE — track_displacement divides by dt (per-second speed); test_geometry.py locks
FPS-invariance. NOTE: motion_thr is now per-second and needs on-device re-tuning.
- [x] **P2-6 [M]** `edge/tracking.py:50` — greedy IoU matching swaps IDs when workers cross,
transferring FALLEN state + frozen baseline to the wrong person. **Fix:** gate matches;
reset per-track safety state on a low-confidence match.
DONE — global greedy (all pairs sorted by IoU desc) replaces track-order greedy; any track
in a contended match (person↔2 tracks or track↔2 persons) is reported via
`tracker.ambiguous()` and run_once resets its engine + smoother state. A still-prone worker
re-confirms within prone_confirm_s. test_tracking.py + test_app.py lock both.
- [x] **P2-7 [M]** `edge/detectors/fall.py:92` — a single prone-looking frame sets FALLING and
arms a CRITICAL ghost if the person then leaves. **Fix:** require sustained prone.
DONE — STANDING→FALLING now requires sustained prone (`falling_confirm_s`, default 1.0s,
dataclass-only, no env knob). FALLEN/no_motion timing unchanged (keyed off prone_since).
test_fall.py locks it.
- [x] **P2-8 [M]** `edge/app.py:30` — detection dropouts keep a track 'active' with a frozen
frame; time-based timers accrue on stale data. **Fix:** treat a stale track as no-data.
DONE — run_once feeds the engine (and smoothers) only tracks re-detected THIS tick
(`latest.t == now`); a stale track is no-data until re-detected or lost. Re-detection still
confirms the fall (dropout pauses evidence, doesn't erase it). test_app.py locks both.
- [x] **P2-9 [M]** `edge/detectors/zone.py:45` — foot point averages both ankles with no
confidence check; a hallucinated ankle at (0,0) drags the foot into/out of a zone. **Fix:**
confidence-gate the ankles, fall back to bbox-bottom-center.
DONE — `_foot` averages only ankles with conf ≥ MIN_ANKLE_CONF (0.2, the existing 'visible'
gate); with neither trustworthy it falls back to bbox bottom-centre. No tuning knob — reuses
the codebase's keypoint-visible convention. test_zone.py locks the fallback.
- [x] **P2-10 [M]** `edge/adapters/movenet_pose.py:34` — the CPU MoveNet fallback is
single-person; with 2+ workers all but one are invisible to every detector, no warning.
**Fix:** warn loudly that MoveNet fallback is single-person (Hailo is multi-person).
DONE — MoveNetPoseAdapter.__init__ logs a startup WARNING ("SINGLE-PERSON … use POSE_BACKEND=
hailo at multi-person sites"). test_movenet_pose.py asserts the warning via caplog.
- [x] **P2-11 [L]** `edge/geometry.py:39` — `head_below_hips` uses the NOSE keypoint
regardless of confidence. **Fix:** confidence-gate.
DONE — `head_below_hips` returns False when nose conf < nose_thr (0.2), so an unconfident/
hallucinated nose no longer fires the fall cue. test_geometry.py locks it; test_fall.py stays
green (the cue's confident-nose path is unchanged).
## Phase 3 — Privacy / PIPA (delivery compliance)
- [x] **P3-1 [H]** `edge/app.py:18` — `FaceBlurrer` blurs a fixed ~40×40px patch regardless of
face size; close faces stay identifiable. **Fix:** size the blur box from the person bbox.
DONE — `_head_boxes` sizes the box from the person bbox (scales with distance) + bbox-top
fallback when the nose keypoint is low-confidence. test_faceblur.py locks both.
- [x] **P3-2 [H]** `server/src/Ingest/IngestController.php:68` — worker snapshots are stored
forever; no retention/purge anywhere. PIPA violation. **Fix:** `snapshot_retention_days`
config + a token-guarded purge route + `SnapshotRepository::purgeOlderThan`.
DONE (retention = **7 days**) — `snapshot_retention_days` config; `purgeOlderThan` on the
interface + Pdo + double (files then rows, bounded batch); opportunistic purge in the
ingest path (cafe24 has no cron; the edge POSTs 24/7). IngestControllerTest locks it.
- [x] **P3-2b [M]** `server/src/Dashboard/EventListController.php:17` — `site_scope` is stored
in the session but never enforced; any authenticated user sees every device's worker
snapshots. **Fix:** scope event/snapshot queries by the session's `site_scope`.
DONE (site↔device model approved) — added `devices.site`; ingest denormalizes it onto
`events.site` (server-derived, never client-supplied). `App\Security\SiteScope` centralizes the
rule (NULL scope = global operator). The event LIST filters in SQL (`page(...,$site)`); event
DETAIL + ACK and the SNAPSHOT proxy guard per-row and return 404 (not 403) cross-site so
existence never leaks. Migration ALTERs + provisioning UPDATEs documented in schema.sql.
Tests: list scoped, detail cross-site 404, snapshot cross-site 404 / same-site 200.
- [x] **P3-3 [H]** `edge/tools/fetch_movenet.py:12` — downloads the ONNX from a mutable ref
with no checksum; spec mandates SHA256. **Fix:** pin URL to a commit + sha256+size, mirror
`fetch_hailo_hef.py`.
DONE (with a caveat) — rewrote to stage-validate: rejects HTML/LFS-pointer, optional pinned
sha256+size, temp-file staging, fails LOUD. DISCOVERY: the old URL is DEAD (GitHub 404 / HTML
— PINTO hosts models off-repo), so it was silently saving garbage. No known-good sha256 to
pin until a valid MOVENET_ONNX_URL is set. NON-BLOCKING for the Hailo delivery path (MoveNet
is the optional CPU fallback); flag for whoever needs CPU MoveNet.
CLOSED 2026-09-21 — 유효한 출처를 찾아 고정했다(tf2onnx 변환본, sha256+size). 고정 전에
어댑터 계약과 대조했다: 입력 `[1,192,192,3] int32` NHWC, 출력 `[1,1,17,3]`. 레이아웃이 다른
변환본은 **로드는 되고 좌표만 틀리는** 실패라, 그 계약을 테스트로 박았다
(`tests/hardware/test_movenet_smoke.py::test_model_signature_is_what_the_adapter_feeds_and_reads`).
실사진 2장으로 코<어깨<엉덩이<발목 세로 순서와 bbox 프레임 내 포함까지 확인.
## Phase 4 — Server hardening (shared hosting)
- [x] **P4-1 [H]** `server/public/index.php:106` — no top-level try/catch; a PDOException
becomes a raw 500 that leaks DSN/db_user if `display_errors` is on (cafe24 often ships On).
**Fix:** global try/catch → generic 500 + `error_log`; `ini_set('display_errors','0')`;
ship `.user.ini`.
DONE — index.php sets display_errors=0 + a set_exception_handler → generic {"error":"internal"}
+ error_log; a `.user.ini` (display_errors=Off) ships in www/move_detect/ as the pre-parse
backstop. Package build verified.
- [x] **P4-2 [H]** `server/src/Api/ConfigController.php:22`, `HeartbeatController.php` — no rate
limiting (unlike /api/events). **Fix:** per-IP + per-device limiter, fail-open on storage
error.
DONE — both take an optional RateLimiterRepository + clientIp; pre-auth per-IP limit
(shared ingest_ip cap) returns 429; index.php passes REMOTE_ADDR. ApiSyncTest locks the flood.
- [x] **P4-3 [M]** `server/src/Repository/Pdo/PdoNonceRepository.php:30` — nonces + rate_limits
grow without bound; no cron on cafe24. **Fix:** opportunistic in-request reap of expired
rows.
DONE — remember() reaps expired nonces; hit() reaps stale rate-limit windows (bounded,
gated 1/100). Full PHP suite green.
- [x] **P4-4 [M]** `server/src/Http/Router.php:18` — route params are never `rawurldecode`d; a
`camera_id` with a space/`+`/Korean saves zones but never loads them. **Fix:** decode
captured params.
DONE — dispatch rawurldecodes captured params; BasePathTest locks space + Korean cases.
- [x] **P4-5 [M]** `server/src/Dashboard/EventListController.php:17` — event list hard-capped at
newest 100, no pagination/date filter; older events unreachable. **Fix:** cursor pagination.
DONE — added keyset `EventRepository::page(limit, severity, beforeTs, beforeId)` (Pdo + InMemory),
a `(occurred_at DESC, event_id DESC)` cursor stable under concurrent inserts. Controller takes
`limit` (default 50, cap 200) + `cursor` ("occurred_at:event_id"), returns `next_cursor` on full
pages. `recent()` kept for existing callers. The live-tail dashboard consumes page 1; a
history-browse UI can layer on the same cursor later. Test `testListPaginatesWithCursor`.
- [x] **P4-6 [M]** `server/src/Repository/Pdo/PdoZoneRepository.php:86` — editing a zone with a
changed device_id/camera_id throws an uncaught RuntimeException → 500. **Fix:** 400 with a
clear error.
DONE — ZoneConfigController::save wraps `$this->zones->save()` in try/catch(\RuntimeException)
→ `invalid_zone` 400; test `testSaveOwnershipMismatchIs400NotError` with a throwing repo double.
- [x] **P4-7 [L]** `server/src/Ingest/IngestController.php:64` — snapshot write failure is
swallowed (201 with evidence lost); file-write/DB-insert non-atomic → orphan JPEGs. **Fix:**
order write→insert, log failures, reconcile orphans.
DONE — extracted `storeSnapshot()`: re-encode→write→index in order; every failure (re-encode,
file write, DB index) now `error_log`s instead of a silent `continue`; a DB-index failure after
the file lands `@unlink`s the orphan. A snapshot problem no longer fails the ingest — the event
is already stored, 201 stands. Test `testSnapshotIndexFailureCleansOrphanFileButKeepsEvent`
(throwing repo double → asserts 201, event stored, no leftover *.jpg).
- [x] **P4-8 [L]** `server/src/Auth/AuthController.php:31` — login form emits a CSRF token that
`login` never checks. **Fix:** verify it (or drop it).
DONE — verified (completed the half-built defense, not dropped). login() checks
`Csrf::check($sess->get('csrf'), $req->post('csrf'))` → 403 before the user lookup, closing the
login-CSRF / session-fixation hole. Tests updated to seed the session+body token; added
`testLoginRejectsMissingOrWrongCsrf`.
- [x] **P4-9 [L]** `server/src/Auth/AuthController.php:71` — logout calls `regenerate()` after
`session_destroy()` → E_WARNING, old cookie not replaced. **Fix:** reorder.
DONE — logout() rotates the id then wipes (was regenerate-after-destroy).
- [x] **P4-10 [L]** `server/src/Ingest/EventValidator.php:26` — no string length caps → an
overlong id becomes a MySQL 'Data too long' 500 retry loop instead of a 400. **Fix:** cap
lengths in the validator.
DONE — EventValidator caps device_id/camera_id (64) + zone_id (32); EventValidatorTest.
- [x] **P4-11 [L]** `server/src/Http/Request.php:45` — `pathFor` routes any `//...` URI to `/`
(parse_url scheme-relative). **Fix:** collapse/normalize.
## Phase 5 — Install / bundle / supply-chain
DONE — pathFor collapses leading slashes before parse_url; BasePathTest locks it.
- [x] **P5-1 [B]** `deploy/bundle/install_offline.sh:79` — the OFFLINE installer has no
backend-usability gate (setup_online's [6/8] does): it installs `POSE_BACKEND=movenet`
verbatim even when the bundle carries no working backend. **Fix:** mirror the [6/8] probe.
DONE — [5/6] probes HEF+hailo_platform / onnx+onnxruntime, reconciles POSE_BACKEND in the
installed env, hard-fails if neither. test_deploy_artifacts.py locks the gate.
- [x] **P5-2 [H]** `deploy/bundle/build_offline_bundle.sh:55` — a native build on x86 produces
an aarch64-named tarball full of x86_64 wheels. **Fix:** assert `uname -m == TARGET_ARCH`.
DONE — native branch now hard-fails when uname -m != TARGET_ARCH.
- [x] **P5-3 [M]** `deploy/bundle/build_offline_bundle.sh:65` — the air-gapped bundle never
carries the Hailo HEF, so offline Hailo install is impossible. **Fix:** stage the HEF (via
`fetch_hailo_hef --arch`) into the bundle.
DONE — build now fetches the `$HAILO_ARCH` (default hailo8) HEF via `fetch_hailo_hef` (pinned
sha256, aborts on mismatch) and stages `yolov8s_pose.hef`. Also fixed two latent breaks that
made the offline path unusable regardless: the MoveNet ONNX fetch was unconditional so its dead
source aborted the whole build under `set -e` (now guarded/optional → Hailo-only bundle), and
`install_offline.sh` copied only `*.onnx` so a bundled HEF never reached `$PREFIX/models` for the
backend gate to find (now copies `*.hef` too). MANIFEST lists models + available pose_backends.
Tests: fetches/stages HEF, MoveNet optional, installer copies the HEF.
- [x] **P5-4 [M]** `deploy/bundle/build_offline_bundle.sh:28` — pip deps unpinned, no digests,
MANIFEST has no hashes, bundle unsigned; spec requires SHA256/GPG + SBOM. **Fix:** pin +
hash + manifest digests.
DONE — build emits `SHA256SUMS` (sha256 of every wheel + model), `requirements.lock` (exact
`name==version` pins for a reproducible rebuild), and `sbom.json` (CycloneDX 1.5: name, version,
pypi purl, sha256). Optional detached GPG signature via `SIGN_KEY` → `SHA256SUMS.asc`.
`install_offline.sh` runs `sha256sum -c SHA256SUMS` (and verifies the signature if present)
BEFORE any pip install, hard-failing on mismatch. Generation logic verified end-to-end on a
synthetic stage (valid JSON, `sha256sum -c` passes). Tests lock the manifest + the install-time
check.
- [x] **P5-5 [H]** `deploy/systemd/move-detect-edge.service:46` `OFF-HW` — `DeviceAllow` omits
`/dev/media*` and `/dev/dma_heap`; picamera2/libcamera on Pi 5 needs them, so the CSI camera
is denied by the unit's own policy. **Fix:** add the device classes; verify on hardware.
DONE — added `char-media` + `/dev/dma_heap/`. OFF-HW: confirm on the Pi with the CSI camera.
- [x] **P5-6 [L]** `server/deploy/build_cafe24_package.sh` — dev cache dirs
(`.impeccable/`, `.ruff_cache/`) can ship inside the web-served package. **Fix:** exclude.
---
## Phase 6 — 재감사 2026-08-18 (안전 플랫폼 확장 표면)
커밋 `e00ad69` 로 감사 표면이 크게 늘었다: 신규 라우트 17개, DB 표 6종 + 컬럼 4종, CLI 도구
5개, 엣지 모듈 4개(GPIO 출력 포함), 통보 발송 경로(메일·웹훅) 신설, 감사로그 해시체인.
8개 관점(보안·데이터 정합·렌더·패키징·문서 정합·성능·UX·회귀)으로 재감사, 확정 12건 전량 해소.
### CRITICAL
- [x] **A1 [B]** `server/src/Ingest/EventValidator.php` — 신규 감지 유형(`camera_moved`,
`camera_obstructed`, `low_light`)이 ingest 허용 목록에 없어 **서버가 전부 400 으로 거부**했다.
`edge/queue.py` 는 20회 재시도 후 그 행을 버리므로, 카메라 무결성 감지는 구현·테스트·배포가
전부 끝난 상태로 end-to-end 무력화되어 있었다 — 로그를 뒤지지 않는 한 아무도 모른다.
DB CHECK 와 프런트 라벨은 갱신했는데 **가운데 검증 계층만** 빠진, 계층 간 목록 중복의 전형적 결함.
DONE — 허용 목록 갱신 + **언어 경계를 넘는 회귀 테스트** 신설
(`tests/test_event_type_parity.py`: `edge/events.py` ↔ EventValidator ↔ schema.sql ↔
프런트 라벨 5종 ↔ 유형 필터). 파이썬·PHP 어느 쪽 테스트도 잡을 수 없던 자리다.
- [x] **A2 [B]** `server/tools/seed_demo.php` — `--purge` 가 위험성평가를
`reviewed_by='안전관리자' AND site='factory-a'` 로 지웠다. '안전관리자'는 이 제품의 **기본
관리자 계정명**이고 'factory-a'는 흔한 현장명이라, 운영 DB 에서 한 번 실행하면 실제
위험성평가가 복구 불가능하게 사라진다.
DONE — 데모 행은 `DEMO_PREFIX`/`DEMO_ACTOR`/`DEMO_LABEL` 표식으로만 식별. 표식 없는 행은
어떤 경우에도 건드리지 않는다.
- [x] **A3 [B]** `server/tools/backup.php` — 웹 공개 경로 차단이 `str_contains($real, '/www/')`
라 **docroot 자체**(`/home/acct/www`)는 뒤에 슬래시가 없어 통과했다. 가장 흔한 오사용이
정확히 그 경우다. 전 현장의 사고 기록 + 작업자 이미지가 웹에서 내려받힌다 (파일명은
`move_detect-YYYYmmdd-HHMMSS.sql` 로 하루 범위만 알면 추측 가능).
DONE — 경로를 세그먼트로 쪼개 `www/public_html/htdocs/html/web/public` 중 하나라도 있으면 거부.
회귀 검증: docroot 자체·하위 경로 모두 차단, 비공개 경로는 통과.
### HIGH
- [x] **A4 [H]** 신규 유형이 `app.js`/`detail.js`/`report.js` 의 `TYPE_LABELS` 와 이벤트 유형
필터에 없어 화면에 `camera_moved` 같은 원시 코드가 그대로 표시됐다. 조사자가 그 유형만
걸러 볼 방법도 없었다. DONE — 3개 맵 + 필터 갱신, A1 의 파리티 테스트가 재발을 막는다.
- [x] **A5 [H]** 서버가 `date_default_timezone_set` 를 하지 않아 통보 메일의 시각이 PHP 기본
(공유호스팅은 흔히 UTC)으로 찍혔다. 대시보드는 브라우저 시각을 쓰므로, 콘솔이 13:01 로
보여준 넘어짐을 메일이 04:01 로 적는다 — 응급 대응에서 읽는 유일한 문장이 9시간 어긋난다.
DONE — `Config::timezone()` (기본 `Asia/Seoul`, 잘못된 값이면 폴백하되 발송은 막지 않음),
`Channels` 가 프로세스 전역 기본값을 건드리지 않고 해당 시간대로 렌더.
- [x] **A6 [H]** 마이그레이션의 `ALTER TABLE … DROP CHECK` 는 MySQL 8.0.16+ 전용 문법이다.
카페24 공유호스팅에 흔한 **MariaDB 는 `DROP CONSTRAINT` 를 쓰므로 마이그레이션이 통째로 실패**한다.
DONE — 해당 블록에 엔진별 분기(A: MySQL 8.0.16+ / B: MariaDB / C: 제약 없음) 를 명시하고,
이 CHECK 가 2차 방어선이며 1차는 `EventValidator` 임을 적어 실패해도 판단할 수 있게 함.
적용 확인용 probe INSERT/DELETE 도 함께 기재.
### MEDIUM
- [x] **A7 [M]** `AlertDispatcher::run` 이 매분 창 전체를 다시 훑으며 이미 보낸
(event, channel) 쌍마다 실패할 INSERT 를 한 번씩 재시도했다. 비용이 '새 이벤트'가 아니라
'창 길이'에 비례해, 크론이 불안정해 창을 넓힌 현장일수록 부하가 커진다.
DONE — `claimedSince()` 로 창의 원장을 한 번에 읽어 사전 필터. `claim()` 은 여전히 최종
권위(경합 시)다. 회귀 테스트가 2·3회차 실행에서 claim 시도가 0 임을 고정.
- [x] **A8 [M]** `move_alert_log` 에 보존 정책이 없어 유일하게 무한 증가하는 표였다
(이벤트 보존은 기본 비활성이라 CASCADE 도 일어나지 않는다).
DONE — `maintain.php` 가 해소된 전달(sent/suppressed)만 정리. **failed 는 남긴다** —
"아무에게도 닿지 않았다"는 사실이 조사에서 필요한 발견이라 청소가 지우면 안 된다.
- [x] **A9 [M]** 전역 관리자가 현장 채널을 수정하면 `site` 가 NULL 로 덮여 그 채널이 **모든
현장에 발화**하게 됐다. 폼 어디에도 그런 안내가 없다.
DONE — 수정 시 기존 site 보존, 신규 생성만 호출자 스코프. 양쪽 회귀 테스트 추가.
- [x] **A10 [M]** 일괄 확인이 대상마다 fetch 를 하나씩 띄웠다. 7건에서는 무해하지만 실제
백로그는 수백 건이고, HTTP/2 에서는 브라우저가 직렬화하지 않아 공유호스팅에 한꺼번에 떨어진다.
DONE — 동시 4개로 제한하고 진행 중에도 목록을 갱신(멈춘 것처럼 보이지 않게), 완료 토스트에
`성공/전체` 를 함께 표기.
### LOW
- [x] **A11 [L]** 패키지 테스트가 `tools/` 배치를 검사하지 않았다. 이 도구들은 메일을 보내고
DB 전체를 읽으므로 www/ 아래로 새면 인증 없는 표면이 된다.
DONE — ① 모든 CLI 도구가 `bootstrap.php`(SAPI 가드)를 require 하는지 ② 패키지에서 tools/ 가
private 폴더에만 있는지 ③ 웹 루트의 PHP 가 `index.php`/`_preflight.php` 뿐인지 검사.
- [x] **A12 [L]** 감사 리포트 판정 배너와 `CLAUDE.md` 의 "43/43" 표기가 stale.
DONE — 이 문서 상단 배너 + CLAUDE.md 갱신.
### 재감사에서 확인만 하고 수정하지 않은 항목 (판단 기록)
- **웹훅 SSRF (내부망 주소 지정 가능)** — 채널 등록은 admin 전용이고, 이 기능의 정당한
용도가 바로 **사내망** 그룹웨어·문자게이트웨이다. 사설 IP 대역을 막으면 기능 자체가
성립하지 않는다. http(s) 한정 + 리다이렉트 금지 + 토큰 헤더까지가 방어선. 수용.
- **서버가 엣지 GPIO 핀 번호를 지정** — 손상된 서버가 임의 핀을 구동할 수 있다. 다만 서버는
이미 존(감지 판정 자체)을 내려보내는 신뢰 주체이고, 핀 범위는 서버측에서 0~40 으로 검증된다.
이 신뢰 경계를 좁히려면 장치측 화이트리스트가 필요 — 실장비 커미셔닝 항목으로 이관.
- **`/setup` 4단계가 첫 카메라의 존만 확인** — 다른 카메라에 존이 있어도 "없음"으로 보일 수
있다. 상태 문구에 "첫 카메라에" 를 명시해 오해를 줄였고, 전 카메라 조회는 온보딩 화면이
N+1 요청을 내는 대가가 이득보다 크다고 판단. 수용.
---
## 3차 재감사 — 1.1.0 장치 건전성 표면 (2026-09-04)
범위: 1.0.0(`9596d1b`) 이후 변경된 서버 코드 전량 — `DeviceHealthWatch`(신규),
`dispatch_alerts.php` 1단계 배선, `Config.php` 설정 4종, `__device__` 센티넬 렌더 경로,
config 템플릿 2종. `edge/` 는 불변. 4개 관점 병렬 감사 후 각 발견을 반증 시도해 확정.
### CRITICAL
- [x] **R3-1 [B]** `dispatch_alerts.php` 1단계가 try 밖에 있어, 장치 건전성에서 난 예외가
**통보와 재시도까지 통째로 죽였다.** 이 단계만 `move_devices`·`move_device_metrics` 를
읽으므로(통보 경로는 두 표를 안 건드림), 마이그레이션 미적용·커넥션 한도·lock timeout 이
원래대로면 정상 발송됐을 실행을 매분 죽인다 — 새벽 3시 낙상이 통보되지 않고 콘솔은 멀쩡해
보인다. DONE — 1·2단계를 각각 격리하고 3단계의 `exit(1)` 을 제거해 4단계 재시도가 계속 돈다.
- [x] **R3-2 [B]** 간헐 고장에 감지기가 **양방향으로** 무너졌다. `edge/daemon.py:105` 는 성공
프레임 하나에 streak 를 지워 `cameras_erroring` 를 0 으로 되돌린다(리본 접촉 불량·USB
브라운아웃의 전형). 정상 표본 하나로 저하 구간을 끊는 구현은 (a) 두 시간 고장에도 지속
시간이 sustain 을 못 넘어 아무것도 못 올리고, (b) 끊긴 구간마다 새 앵커가 생겨 같은 고장을
수십 건으로 쏟아냈다(6시간 시뮬레이션 54건). DONE — 엣지 낙상 감지기의 `recover_grace_s`
와 같은 규율로 짧은 정상 표본을 흡수하고, uuid 에 재고지 버킷을 더해 주기당 1건으로 묶었다
(같은 입력 6시간 ≤7건). 기존 16개 테스트가 전부 단조 시퀀스라 이 결함이 CI를 통과했다 —
플래핑·폭주·회복·미래시각 테스트 5건 추가.
### HIGH
- [x] **R3-3 [H]** 센티넬 `__device__` 가 **메일·웹훅으로 회사 밖에 나갔다**
(`[경고] 장치 이상 — __device__`). 라벨을 `app.js` 에만 넣은 것이 원인.
DONE — `App\DeviceScope`(서버)·`assets/device-scope.js`(프런트) 단일 출처로 모으고
통보·상세·리포트·사고조사 패키지·설정 점검·컴플라이언스 전 경로에 적용.
- [x] **R3-4 [H]** 사건 상세의 "이 카메라 구역 설정" 링크가 `?camera=__device__` 를 넘겨,
**어떤 카메라와도 매칭되지 않는 구역**이 '활성'으로 저장될 수 있었다(아무것도 지키지 않음).
DONE — 링크를 감추고 `ZoneConfigController` 가 400 으로 거부.
- [x] **R3-5 [H]** 인제스트가 센티넬을 예약값으로 다루지 않아, 엣지가 그 `camera_id` 로 올린
**진짜 낙상**이 "장치 전체" 로 이름 붙고 카메라 목록·구역·통계에서 빠졌다 — 변경이 실제
사고를 화면에서 더 안 보이게 만들었다. DONE — `EventValidator` 가 `reserved:camera_id` 로 거부.
- [x] **R3-6 [H]** `BUILD-INFO.txt` 의 `git_commit` 이 릴리스 직전 커밋을 가리켰다(패키지는
1.1.0 내용, 스탬프는 1.0.0 트리). DONE — 릴리스 커밋 이후 재생성.
- [x] **R3-7 [H]** 실제 자격증명 파일(`PROVISION_SECRETS.md`)이 납품 폴더 `dist/` 안에 있었다.
zip 에는 안 들어갔지만 폴더째 압축하면 그대로 나간다. DONE — `secrets/` 로 이동(gitignore).
**잔여 조치: 해당 계정 비밀번호와 장치 HMAC 키 교체 권고** (아래 잔여 절).
### MEDIUM
- [x] **R3-8 [M]** 카메라별 집계 3곳에 센티넬이 섞였다 — "카메라별 위험 순위"에 유령 카메라가
오르고, 사이트별 "카메라 N대"가 한 대씩 부풀고("카메라 한 대로 지키는 현장"을 드러내려는
숫자라 목적 자체가 훼손), 오탐율 패널에도 나타났다. DONE — `NOT_DEVICE` 조각으로 제외.
- [x] **R3-9 [M]** 설정 점검(`/setup`)이 노드 장애 1건을 카메라로 세어 ③을 초록으로 통과시키고,
그 값으로 ④의 구역을 조회해 정상 현장에 "구역 없음"을 띄웠다. DONE.
- [x] **R3-10 [M]** 개선 조치를 센티넬에 기록할 수 있어, "조치 전후 감지 밀도"가 노드 장애
건수로 계산됐다. DONE — 폼을 감추고 `ActionsController` 가 거부.
- [x] **R3-11 [M]** 라벨 데이터셋(CSV)에 노드 사건이 `unreviewed` 로 영구히 쌓였다(스냅샷이
없어 프레임으로 라벨링 불가). DONE — 내보내기에서 제외.
- [x] **R3-12 [M]** 낡은 지표 행으로 "지금 저하 중"을 판정할 수 있었다. DONE — 최신 표본이
`device_stale_seconds` 보다 낡으면 저하 판정을 하지 않는다(죽은 노드는 device_fault 가 잡음).
- [x] **R3-13 [M]** 미래 시각 지표 표본 하나가 영원히 "최신"으로 고정돼 실제 저하를 가리거나
없던 장애를 sustain 없이 통과시켰다. DONE — `ts > now` 표본 제거.
- [x] **R3-14 [M]** 죽은 노드가 첫 시각 이후 영원히 조용했다(`device_fault` 는 warning 이라
에스컬레이션 대상도 아님) — 더 가벼운 `monitoring_degraded` 는 주기적으로 다시 올라오는데
방향이 반대였다. DONE — 재고지 버킷을 두 유형에 동일 적용.
### 반증되어 채택하지 않은 것 (REFUTED)
- 센티넬이 히트맵을 왜곡한다 → `spatial_x/y IS NOT NULL` 조건에 걸려 점이 하나도 안 들어간다.
- 센티넬이 에스컬레이션으로 번진다 → `EscalationWatch` 는 critical/high 만 올리고 장치 이벤트는
warning 이다. (심각도를 올리면 살아나므로 주석으로 표시.)
- 스트림 카메라 필터가 유령 카메라로 고정될 수 있다 → 필터는 레일 트리에서만 설정되고
센티넬은 트리에 들어가지 않는다.
- 설정 4종이 배선되지 않았다 → 키 이름까지 일치하며, 템플릿 누락은 기존 테스트가 자동으로 잡는다.
### 이번 재감사에서 확인한 잔여 (수정하지 않음, 사유 명시)
- **자격증명 교체** — `secrets/PROVISION_SECRETS.md` 의 대시보드 비밀번호와 장치 HMAC 키는
6주간 납품 폴더 안에 있었다. 유출 정황은 없으나(git 미추적·zip 미포함) 교체를 권고한다.
운영 계정 조작이라 이 저장소에서 수행하지 않는다.
2026-09-21 — 손으로 적던 절차를 도구로 바꿨다(`server/tools/rotate_credentials.php`).
**실행은 여전히 운영자 몫이다.**
- **Pi 번들 재빌드** — 1.1.0 은 서버 전용이라 `edge/` 가 불변이고 내용은 1.0.0 과 동일하지만,
파일명·MANIFEST 의 버전 표기가 1.0.0 이다. arm64 + 네트워크가 있는 빌드 머신에서만 재빌드
가능하므로 Pi 납품 시 그 머신에서 수행한다.
RESOLVED 2026-09-21 — `--cross` 모드로 해소했다. arm64 머신은 필요 없었다: pip 이 태그로
aarch64/cp311 휠을 받으므로 네트워크만 있으면 된다. 휠 11개 전부 aarch64.
- **매뉴얼에 신규 2종 설명 없음** — `manual.json` 에 `장치 이상`·`감시 성능 저하` 항목이 없다
(1.0.0 의 `미확인 경보`도 마찬가지라 이번 회귀는 아니다). 캡처는 실화면이고 UI 골격은 바뀌지
않았다. 다음 매뉴얼 개정에서 함께 넣는다.
- **크론 중복 실행 잠금 없음** — 재시도 패스에 claim 이 없어 1분을 넘기는 실행이 겹치면
중복 발송이 가능하다. 1.0.0 부터 있던 성질이고 이번 변경이 만든 것이 아니다.
---
## 4차 재감사 — 1.2.0 추락·동시 다발·프리셋 (2026-09-05)
범위: 1.1.0 이후 변경 전량. 4개 관점 병렬 감사, 각 발견을 반증 시도해 확정.
**이번 감사의 요지: 구현·단위테스트가 모두 초록인데 납품 경로에서 동작하지 않는 결함이
셋 있었다.** 전부 "테스트가 기본값이 아닌 설정을 썼다"거나 "상류 허용 목록을 안 봤다"는
같은 모양이다.
### CRITICAL
- [x] **R4-1 [B]** 추락 승격이 **기본 설정에서 한 번도 발화하지 않았다.** `link_s`(3.0)를
`now` 기준으로 쟀는데 넘어짐은 `prone_confirm_s`(3.0) 지속 후 확정되므로 확정 시점에는
항상 3초 넘게 지나 있다. 다른 테스트가 전부 `prone_confirm_s=0.5` 를 써서 초록이었다.
DONE — 착지(prone 시작) 시각과 비교(`landed_from_drop`). **기본값 그대로 도는 회귀
테스트를 추가**했다(설정을 낮추지 말 것이라고 테스트에 적었다).
- [x] **R4-2 [B]** `max_workers: 0` 이 상류 허용 목록 두 곳에서 막혀 있었다 —
`ZoneConfigController::RULE_BOUNDS` 가 `[1,50]` 이라 저장이 400, `edge/zones.py` 가
`v <= 0` 으로 규칙 **전체**를 무효화(같이 넣은 2인1조까지 사라짐). 앞서 고친 네 곳은
전부 이 둘보다 하류라 도달 불가였다. DONE.
- [x] **R4-3 [B]** 고소작업 프리셋(`elevated`)이 **완전히 무동작**이었다. 서버 경계에 키가
없어 400, 통과하더라도 엣지 파서가 침묵 삭제. 프리셋 안내문과 커밋 메시지가 시스템이
하지 않는 일을 약속하고 있었다 — 이 제품에서 가장 나쁜 종류의 결함이다. DONE.
- [x] **R4-4 [B]** 동시 다발이 **한 사람을 두 명으로 셀 수 있었다.** 트래커는 1초 가려짐에
트랙을 버리고 새 id 를 주고(`max_age=1.0`), 누운 사람은 새 트랙에서도 즉시 prone 이라
두 번째 `fall` 이 난다. 그러면 다친 한 사람에게 "공기호흡기 없이 진입 금지"가 나가
**구조가 늦어진다** — 기능의 가치가 정반대로 뒤집힌다. DONE — `ghost` 를 인원 집계에서
빼고, 거의 같은 자리(0.08 미만)의 사건은 같은 사람으로 본다.
### HIGH
- [x] **R4-5 [H]** 구역 그룹핑이 사문화돼 있었다 — 엣지가 넘어짐·미동·소실에 `zone_id` 를
싣지 않아 카메라가 다른 두 희생자가 절대 묶이지 않았다(밀폐공간이 정확히 그 상황).
DONE — 사람 상태 이벤트에 구역을 싣고, 소실 이벤트는 마지막 구역을 물려준다.
- [x] **R4-6 [H]** 창이 미끄러지면 `group[0]` 이 바뀌어 uuid 가 달라져, 한 사고가 매 분
새 위급 건으로 쏟아지고 인원이 줄면 앞선 알림을 스스로 뒤집었다. DONE — 이미 올린
무리는 인원이 늘 때만 다시 올린다.
- [x] **R4-7 [H]** 무장이 만료되지 않아 발판에 잠깐 올라갔던 사람이 몇 분 뒤 걸어 나가도
추락 경보가 됐다. `engine.forget()` 이 ghost 상태를 안 지워 트랙 교체 시 다른 사람이
무장을 물려받았다. 프레임 안에서 이미 낸 추락이 소실 경로에서 한 번 더 났다. DONE.
- [x] **R4-8 [H]** 업그레이드 안내에 마이그레이션 2개가 빠져 있었다. 그중 하나가 없으면
신규 유형 INSERT 가 DB CHECK 에 전부 걸린다 — 감지기는 도는데 화면에 아무것도 안 뜬다.
DONE + **README 가 실제 마이그레이션을 전부 담는지 검사하는 테스트** 추가.
- [x] **R4-9 [H]** `BUILD-INFO` 의 커밋 스탬프가 **1.1.0 에서 고친 결함(R3-6)의 재발**.
절차로 막으려다 두 번 실패했으므로 DONE — HEAD 와 일치하는지 검사하는 테스트로 막았다.
### MEDIUM
- [x] **R4-10 [M]** 정비·작업허가 억제가 "공기호흡기 없이 진입 금지"를 삼켰다. 허가가
발급된 그 구역·그 시간이 질식이 일어나는 자리다. DONE — 위급 3종은 억제 예외.
- [x] **R4-11 [M]** 미래 시각 이벤트가 무관한 넘어짐과 짝지어져 가스 경보를 만들 수 있었다
(폐쇄망은 NTP 부재가 흔하다). DONE.
- [x] **R4-12 [M]** 문자열 접두사 비교로 `cam-1` 과 `cam-10` 이 한 사건으로 합쳐졌다. DONE.
- [x] **R4-13 [M]** 좌표 없는 희생자와 좌표 있는 희생자가 영영 안 묶여 진짜 동시 다발을
놓쳤다. DONE — 좌표가 하나라도 없으면 쪼개지 않는다.
- [x] **R4-14 [M]** 데모 시더 심각도가 제품과 **7종** 어긋나 있었다. 특히 `fall` 이 위급으로
찍혀 1.2.0 의 요점(넘어짐 HIGH / 추락 CRITICAL)이 데모에서 지워져 있었다. 라벨과 달리
화면이 깨지지 않아 눈으로는 안 잡힌다. DONE + 엣지를 단일 출처로 강제하는 테스트.
- [x] **R4-15 [M]** 리포트의 사고 집계에서 추락이 빠져 니어미스:사고 비율이 가장 중대한
유형을 빼고 계산됐다. DONE.
- [x] **R4-16 [M]** `elevated` 가 저장 후 초기화되지 않아, 고치는 순간 이후의 모든 구역이
조용히 고소 구역이 될 상태였다(최상위 심각도 오탐 발생기). DONE — 저장·카메라 전환·
지우기에서 초기화하고 목록에 '고소'로 되읽기.
- [x] **R4-17 [M]** `GET /api/zone-presets` 만 인증 게이트가 없었다. DONE.
- [x] **R4-18 [M]** 문서의 "17종"이 실제와 달랐다(활성 20종 = 엣지 16 + 서버 4).
노조 대응 문장에도 쓰이는 숫자다. 소개서·덱·노조 문서·브리프 DONE, 신규 2종 행 추가.
- [x] **R4-19~23 [M/L]** 음수 입력 침묵 폐기, PROJECT_MAP 신규 파일 누락, 소개서 버전
표기, 마이그레이션 수, 프리셋 왕복 테스트 부재. 전부 DONE.
### 반증되어 채택하지 않은 것 (REFUTED)
- ghost 후보 튜플 확장이 어딘가에서 unpack 불일치를 낸다 → 생성·소비 모두 6-wide, 다른 소비자 없음.
- `rules` 가 None/비-dict 일 때 `in_elevated_zone` 이 예외 → `(z.rules or {})` 로 방어됨.
- 빈 입력란이 `max_workers: 0` 으로 저장된다 → `parseInt('')` 는 NaN, `NaN >= 0` 은 거짓.
- 구역 편집에서 기존 규칙이 사라진다 → 편집 경로 자체가 없다(삭제 후 재작성).
- 존 id 가 사이트 간 충돌한다 → 서버 생성 32자 난수라 전역 유일.
### 이번 재감사에서 확인한 잔여 (수정하지 않음, 사유 명시)
- **`INFER_EVERY ≥ 2` 에서 추락 신호 약화** — 6fps 에서는 낙하 중 IoU 가 임계 아래로 떨어져
트랙이 끊길 수 있다. 티어2는 원래 반응 granularity 를 포기하는 모드이고, 실장비 벤치마크
없이 임계를 조정하면 감지 **누락**을 만든다. 커미셔닝에서 실영상으로 정한다.
- **프레임 하단에서 시작하는 추락** — bbox 가 화면 끝에서 잘려 하강량이 임계에 못 미칠 수
있다. 고소 구역을 그리면 잡힌다. 화각 설계로 푸는 문제라 코드로 막지 않는다.
- **`DescentConfig` 튜닝 불가** — 다른 임계값과 달리 env·서버 튜닝 경로가 없다. 실장비에서
기본값이 맞는지 먼저 보고 나서 노출한다(지금 노출하면 검증 안 된 노브가 는다).
- **매뉴얼에 신규 유형 설명 없음** — 3차 재감사의 잔여와 같은 항목. 다음 개정에서 일괄.
---
## OFF-HW 잔여 (실장비 커미셔닝에서만 검증 가능)
코드·문서로 닫을 수 없는 항목. 납품 판정을 막지 않되, 현장 설치 시 반드시 수행한다.
- [ ] **GPIO 경광등 실동작** — `edge/beacon.py` 의 핀 구동은 단위 테스트로 증명할 수 없다.
Pi + 릴레이 모듈로 latch/pulse, `active_high` 반전, hold 만료 해제를 육안 확인.
- [ ] **카메라 무결성 임계값** — `IntegrityConfig.moved_mad` / `obstructed_var` /
`low_light_mean` 은 실제 현장 조명·진동에서 튜닝해야 한다. 현재 값은 합성 프레임 기준.
- [ ] **P2-6/7/8** — 낙상 타이밍·트래킹 매칭 (1차 감사에서 이월). 블라인드 수정은 감지 **누락**을
만들 수 있어 실영상 확보 후 조정.
- [ ] **CSI 카메라 / Hailo HEF 추론** — `tests/hardware/` 스킵 항목.
- [ ] **통보 실발송** — 사내 SMTP 릴레이·웹훅 엔드포인트는 현장에만 있다.
`dispatch_alerts.php --dry-run` 으로 대상만 먼저 확인한 뒤 1회 실발송 검증.
---
## Progress log
(updated as each item lands — newest first)
- **2026-07-17 — P1-9 + P3-2b landed (both approved data-model changes).** 40/43 done.
P1-9: edge `event_uuid` idempotency key (stable through the queue) + server dedup on a UNIQUE
column. P3-2b: `devices.site` → denormalized `events.site`; `SiteScope` enforced on the event
list (SQL filter), event detail/ack, and snapshot proxy (per-row 404). Schema migration +
provisioning UPDATEs documented in schema.sql. phpunit 97, pytest 158 — all green. REMAINING 3:
P2-6/7/8 (safety-timing / tracking-match — deferred to on-hardware commissioning).
- **2026-07-17 — off-hardware solo work COMPLETE (35/43).** Landed this pass, each test-locked:
P4-8 (login CSRF), P4-6 (zone 400-not-500), P4-5 (cursor pagination), P4-7 (snapshot atomicity
+ orphan cleanup), P5-3 (HEF in the offline bundle + fixed the broken build), P5-4 (pin + hash +
SBOM + install-time verify), P2-9/10/11 (keypoint confidence gating + MoveNet single-person
warning). phpunit 90, pytest 157 — all green.
REMAINING 8, none solo-doable:
· P1-9, P3-2b — need a DATA-MODEL decision (event dedup key + schema column; site↔device model).
· P2-6, P2-7, P2-8 — safety-timing / tracking-match logic that must be validated ON HARDWARE.
Changing them blind could make fall detection MISS events (worse than today's over-alarm,
which is fail-safe), so they are deliberately deferred to on-device commissioning.
· (P2 threshold notes: motion_thr and the sustained-prone/staleness windows are commissioning
knobs — tune with real camera footage on the Pi.)
- **2026-07-17 — P4-6 + P4-8 landed (server error/auth hardening).** ZoneConfig ownership-mismatch
now 400 not 500 (P4-6); login CSRF token now verified — closes login-CSRF/session-fixation
(P4-8). phpunit 88 green (4 skipped: PDO). Remaining solo: P4-5 (pagination), P4-7 (atomic
snapshot), P5-3 (HEF in bundle), P5-4 (pin+hash+SBOM).
- **2026-07-17 — 17 items landed, all with tests, full suite green.** ALL 4 BLOCKERS cleared
(P1-2, P2-1, P2-2, P5-1) + all Phase-1 silent-failure (P1-1,3,4,5,6,7,8,10,11) + Phase-2 HIGH
correctness (P2-3,4,5). Remaining: P1-9 (deferred to server batch), P2-6..11 (mostly threshold
tuning — needs on-device validation), Phase 3 (privacy — P3-2 retention needs a policy
decision: how many days), Phase 4 (server hardening, 11 items — mostly clear), Phase 5 (P5-2..6).
Nothing committed yet.
DONE — build prunes .ruff_cache/.impeccable/__pycache__/etc from the staged package.