The ingest snapshot loop silently `continue`d on re-encode and file-write failures (evidence lost with a 201), and a DB-index throw after the file landed left an orphan JPEG on disk with no row — or bubbled into a 500 that discarded an already-stored event. Extracts storeSnapshot(): re-encode → write file → index, in that order so bytes exist before a row points at them. Every failure is error_log'd instead of swallowed; a DB-index failure after the write @unlinks the orphan file. A snapshot problem no longer fails the ingest — the event is already persisted, so the 201 stands. Test: testSnapshotIndexFailureCleansOrphanFileButKeepsEvent uses a throwing snapshot repo and asserts 201, the event is stored, and no *.jpg is left in the snapshot dir. phpunit: 90 passing (4 skipped: PDO-backed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
240 lines
No EOL
18 KiB
Markdown
240 lines
No EOL
18 KiB
Markdown
# Delivery Audit — move-detect (납품 전 결함 목록 · 순차 처리)
|
||
|
||
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/cloudclient.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_cloudclient.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 cloud 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.
|
||
- [ ] **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; cloud dedups on it.
|
||
DEFERRED to the cloud-ingest batch (needs a schema column + IngestController dedup);
|
||
sequenced with Phase 4 / P4-7.
|
||
- [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` ↔ `cloud/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.
|
||
- [ ] **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.
|
||
- [ ] **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.
|
||
- [ ] **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.
|
||
- [ ] **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.
|
||
- [ ] **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).
|
||
- [ ] **P2-11 [L]** `edge/geometry.py:39` — `head_below_hips` uses the NOSE keypoint
|
||
regardless of confidence. **Fix:** confidence-gate.
|
||
|
||
## 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]** `cloud/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.
|
||
- [ ] **P3-2b [M]** `cloud/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`.
|
||
NEEDS A DECISION — the schema has `users.site_scope` (free string) but `devices` has NO site
|
||
column and `events` link only by `device_id`. Enforcing scope needs a site↔device model
|
||
(e.g. add `devices.site`, filter events by the caller's site). Deferred until that is
|
||
decided. Interim mitigation: keep every dashboard account admin/single-site so scope is moot.
|
||
- [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.
|
||
|
||
## Phase 4 — Cloud hardening (shared hosting)
|
||
|
||
- [x] **P4-1 [H]** `cloud/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]** `cloud/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]** `cloud/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]** `cloud/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]** `cloud/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]** `cloud/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]** `cloud/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]** `cloud/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]** `cloud/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]** `cloud/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]** `cloud/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.
|
||
- [ ] **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.
|
||
- [ ] **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.
|
||
- [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]** `cloud/deploy/build_cafe24_package.sh` — dev cache dirs
|
||
(`.impeccable/`, `.ruff_cache/`) can ship inside the web-served package. **Fix:** exclude.
|
||
|
||
---
|
||
|
||
## Progress log
|
||
|
||
(updated as each item lands — newest first)
|
||
|
||
- **2026-07-17 — P4-6 + P4-8 landed (cloud 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 cloud 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 (cloud 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. |