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
587 lines
No EOL
46 KiB
Markdown
587 lines
No EOL
46 KiB
Markdown
# 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. |