move_detect/docs/delivery-audit.md
유창욱 3f2823a764 refactor: rename cloud/ to server/ (cafe24 is web hosting, not cloud)
The PHP app runs on Cafe24 shared web hosting -- calling it 'cloud'
was misleading. Renames the directory, the edge client module, and
every doc/script/identifier that used the term.

- cloud/ -> server/ (incl. server/deploy/build_cafe24_package.sh paths)
- edge/cloudclient.py -> edge/serverclient.py, CloudClient -> ServerClient
- cloud/docs/cloud-deploy.md -> server/docs/server-deploy.md
- CLOUD_* env/config keys -> SERVER_*
- tests renamed to match

Tests: PHP 109 pass (4 skipped), pytest 167 pass. dist/ regenerated.
2026-07-20 11:30:52 +09:00

21 KiB
Raw Permalink Blame History

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.

  • 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.
  • 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.
  • 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.
  • 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.
  • 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.
  • 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.
  • 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.
  • 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; 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).
  • 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.
  • 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)

  • 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.
  • 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.
  • 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.
  • 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.
  • 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. 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.
  • 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.
  • 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)

  • 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.
  • 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.
  • 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.
  • 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 — Server hardening (shared hosting)

  • 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.
  • 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.
  • 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.
  • P4-4 [M] server/src/Http/Router.php:18 — route params are never rawurldecoded; 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.
  • 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.
  • 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.
  • 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_logs instead of a silent continue; a DB-index failure after the file lands @unlinks 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).
  • 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.
  • 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).
  • 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.
  • 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.

  • 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.
  • 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. 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.
  • 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.
  • 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.
  • P5-6 [L] server/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 — 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.