감사에서 확정된 잔여 항목을 일괄 처리. 이전 커밋이 증상(스테일 번들)을 고쳤다면 이번에는
그것이 조용히 지나가게 만든 구조를 고친다.
보안
- CSP 도입. 인라인 2블록에 per-request nonce, unsafe-inline 없음. 빌드가 없다는 제약이
nonce를 막지는 않는다. 보안 헤더를 .htaccess 단독에서 PHP로 옮겨 개발/납품이 같아진다.
- /logout에 CSRF. SameSite=Lax가 막아주지만, 사고 대응 중 운영자를 콘솔에서 튕겨내는 건
이 제품에선 실제 가용성 공격이라 예외로 둘 이유가 없다.
- 잠긴 계정 423 → 401. 아무나 이름을 추측해 5회 실패로 잠근 뒤 423을 계정 실재 확인으로
쓸 수 있었다. 잠금 사실은 감사 로그에 남고, 익명 호출자는 못 읽는다.
엣지
- 큐 flush에 회당 상한. 정전 복구 직후 2000건을 비우는 동안 감지가 멈추던 구간을 없앤다.
- 타이머 env 전수 검증. COOLDOWN_S=nan은 dedup을 무력화해 매 틱 발보시켰다.
- T2 프레임 스킵(INFER_EVERY). 스킵 틱은 프레임을 읽어 버퍼만 비우고 링버퍼에 넣지 않는다 —
추론이 없으면 얼굴 박스도 없어 그대로 넣으면 증거 스냅샷에 미블러 얼굴이 실린다.
T3(모션 게이트)는 의도적으로 미구현: 게이트가 틀리면 '움직이지 않는 쓰러진 작업자'에게
추론이 안 돈다. 실영상 없이 임계값을 정할 수 없다.
- systemd 워치독(sd_notify) + 메모리 상한. Restart=always는 죽은 프로세스만 잡고
'살아 있는데 멈춘' 상태는 못 잡는다. UVC 해상도·버퍼 고정(링버퍼 RSS가 웹캠 기본값에 좌우).
테스트 — 감사의 최대 갭
- PDO 통합 테스트가 pdo_sqlite 부재로 통째로 스킵되고 CI도 없어 사실상 한 번도 안 돌았다.
`-d extension=pdo_sqlite`로 되살리고 리포트·히트맵·익스포트 집계 SQL을 최초로 실행한다.
- 얼굴 블러 테스트가 out is not None만 봤다 — blur()가 무동작이어도 통과. PIPA 대응의
유일한 실행 가능한 증거였는데 아무것도 증명하지 못했다. 디테일 파괴를 단언하도록 교체.
- index.php 라우트 테이블 대조(프런트 호출 15개 교차검증), dist↔소스 패리티 테스트,
CI 신설(스킵이 하나라도 있으면 실패).
- ArraySessionStore.regenerate()가 데이터를 지워 실제 session_regenerate_id(true)와
달랐다. 더블이 실구현과 어긋난 자리 하나를 실제로 잡았다.
프런트
- 이벤트 목록 시그니처 재렌더 스킵 + 포커스 복원. aria-live 영역을 5초마다 통째로 다시
만들어 스크린리더가 200행을 재낭독하고 키보드 포커스가 body로 날아갔다. 같은 패턴이
device-tree.js에는 이미 있었다.
- 폴링 중첩 가드(느린 공유호스팅에서 응답 역전), 콜드스타트 실패 표시.
납품
- VERSION 신설, 두 산출물에 version/git_commit/built_at 스탬프. 스테일 번들의 SHA256SUMS는
자기정합적이라 통과한다 — provenance 없이는 하류에서 탐지할 방법이 없다.
- deploy/README·env.example을 Hailo 단독 번들 실제와 일치(템플릿이 없는 ONNX를 가리켰다).
- 이벤트 보존 정책은 기본 0(영구). 산업안전 기록을 시스템이 임의로 지우지 않는다.
검증: pytest 251 / PHPUnit 162, 스킵 0. CSP nonce 결합은 실제 렌더 경로로 확인.
브라우저 실렌더는 MySQL이 필요해 이 환경에서 미수행.
프로젝트 8개 차원 감사(종합 82/100)에서 확정된 상위 3건 수정.
1. 개선 조치(move_actions)가 유일하게 site 스코프 규율에서 이탈 — 스코프 운영자가
타 현장의 조치 기록(카메라 ID·메모·작성자)을 목록에서 읽고, 타 현장 카메라에
조치를 기록해 그 현장 리포트의 전후 효과 지표를 오염시킬 수 있었다.
- move_actions.site 컬럼 + (site, taken_at) 인덱스, 기존 행 백필 마이그레이션
- 카메라 소유 현장은 이벤트에서 해석 (move_cameras는 등록 플로우가 없어 항상 비어 있음)
- 오타 카메라는 400: 전후 효과가 영원히 0/0이 되는 조치를 애초에 못 만들게 한다
- 타 현장 카메라는 404: 존재 여부를 숨기는 기존 규약 유지
2. README-UPLOAD 업그레이드 절차가 마이그레이션 7종 중 1종만 지시했다. move_ 접두
리네임을 건너뛰면 이후 전부 실패하고 전 요청이 500이 된다 — 순서·적용조건·재실행
비안전성을 표로 명시. 문서 4곳의 테이블 수(10→11)·비프리픽스 표기도 정정.
3. 두 납품 번들 재생성: 8/6 이후 4개 기능 커밋이 반영되지 않아 Report/Export/Actions
컨트롤러와 8/7 마이그레이션이 빠진 채였다. 엣지 소스 패리티도 회복.
검증: pytest 235 passed / PHPUnit 147 passed (PDO 4 skip), 번들 산출물 내부 검증
(28 클래스 해석·전 파일 php -l·SHA256SUMS 55행·휠 11개·HEF 다이제스트 일치).
The operator's daily clicks become durable assets:
Label export (GET /api/export/labels?days=N, JSONL download):
- Every 확인/오탐 click is a human verdict on a detection, and each verdict
carries a blurred 3-frame evidence timeline. The export turns that into a
dataset: one JSON line per event with type/severity/camera/zone/metrics/
spatial, label (confirmed | false_positive | unreviewed), labeled_by, and
snapshot uuids (images fetched via /snap, which re-checks session + site).
ergo_report rows are excluded - statistics are not labelable detections.
- Admin-only ('manage_compliance'): bulk export of detection history is a
compliance decision, not an operator convenience. Site scope applies.
- This is where the deferred P1-C evaluation set will come from for free.
Improvement actions (move_actions + GET/POST /api/actions):
- Record a site fix against a camera ("미끄럼 방지 매트 설치"); the report's
new card compares detection density over EQUAL windows before/after the
fix (capped 30d; under 3 days shows "측정 중" - two days of data is noise,
not a verdict). Improvement shows accent, worsening shows red - the card
reports honestly in both directions, which is exactly what makes the
improvement numbers credible.
- Future timestamps and >1y-old entries are rejected (the comparison could
not be fair). Recording needs edit_zones + CSRF.
Verified: PHPUnit 145 pass (5 new: export perm gate + line shape/labels/
snapshot join, action validation, equal-window effect math, pending state)
+ Chrome real-render (action card with better/worse/pending rows, export
link, no console errors).
The accumulated data starts predicting instead of just recording:
- Time pattern (weekday x hour density grid): WHEN the site is dangerous, on
one card - shift-change, post-lunch, Thursday-afternoon hotspots become
visible. Bucketing runs in PHP on the server-local clock (site timezone)
so the SQL stays portable (the sqlite harness has no FROM_UNIXTIME).
- Precursor-spike alert: last-7-days count of precursor signals (near-miss,
zone entries, occupancy violations - signals, not injuries) vs the prior
4-week weekly baseline. Ratio >= 1.5 with at least 5 samples raises a
banner above the KPIs: an alarm on the PRECURSOR of an accident, with a
concrete suggestion (staffing/route/rest review). Below the sample floor
it never fires - a ratio over 2 events is noise, not statistics.
- Period-over-period trends on every KPI: reportSummary gained an optional
upper bound so the identical aggregation runs over the preceding window.
Worsening turns red, improving turns accent - except the near-miss count,
which stays NEUTRAL: more near-misses may mean better detection, not more
danger, and the report must not editorialize that.
Verified: PHPUnit 140 pass (2 new: window separation + pattern bucketing,
spike ratio + sample floor) + Chrome real-render (alert banner, KPI trend
lines, Thursday-afternoon hotspot readable in the grid).
Two operator-value features: evidence that shows HOW it happened, and the
one-line answer to "what happened overnight".
Evidence timeline (closes the P1-B "前中後 스냅샷 배선" follow-up):
- The ring buffer now stores per-frame head boxes, and an event captures the
frames nearest T-2.5s / T-1.0s / T-0 (server cap: 3 snapshots). A fall's
evidentiary value is in the seconds BEFORE the person hit the floor - one
end-state frame never showed how it happened.
- Each past frame is blurred with the head positions of ITS OWN moment -
blurring history with the current tick's boxes would leave a face exactly
where it actually was (PIPA).
- Right after startup the ring is short: fewer frames go out, never zero and
never duplicated. Detail view captions the sequence 이전/직전/발생 시점 by
position (offsets are edge config the server doesn't know).
24h catch-up brief:
- The events console renders one line above the stream on load - "지난 24시간
- 감지 N건 (중대 n) · 니어미스 n · 평균 대응 t · 미확인 n건 남음" - reusing
GET /api/report?days=1, with a link into /report. High-tinted when the
window held critical/high events; hidden entirely until data arrives.
Verified: pytest 235 pass (3 new: timeline selection, short-buffer dedup,
3-frame capture through run_once) / PHPUnit 138 pass + Chrome real-render
(brief line with severe tint on the console; detail page showing the
3-frame timeline with captions; no console errors).
The benefit made visible: /report folds everything the system collects into
the one printable page a 안전관리자 takes into the weekly safety meeting.
- GET /api/report?days=N (7/30/90): severity totals, per-type counts,
worst-camera ranking, response time (TTA - acknowledged critical/high only,
FP-tagged rows excluded: dismissing a false alarm is not an emergency
response; clock-skew guarded), the near-miss pyramid (near_fall vs
fall/no_motion - the Heinrich argument in one ratio), FP rate, summed
posture-load exposure, occupancy-rule violation counts. ergo_report rows
feed the sums but are excluded from "감지 N건" (bookkeeping, not detection).
Fail-closed site scoping throughout.
- /report view: KPI band (중대 이벤트 / 평균 대응 / 니어미스:사고 / 오탐율,
each with its one-line reading), bar lists for types and cameras (critical
presence highlighted), ergo bands as share-of-observed-time, rule tiles.
Print CSS strips the app chrome - the 인쇄 button produces the meeting
handout. New 리포트 nav tab.
- Events console: event-type filter beside severity - the INFO-volume types
(near_fall, ergo_report) can now be folded out of the live stream, or a
single type isolated for investigation.
Verified: PHPUnit 138 pass (3 new: aggregation math incl. TTA/FP-exclusion/
ergo sums, site scope, auth) + Chrome real-render of the full report (KPIs,
bar lists, rule tiles, footer) and the type filter isolating one type.
- cafe24 package now stages server/migrations/ beside schema.sql: schema.sql is
for a FRESH database only, an already-imported host upgrades by running the
migrations in date order (then deletes them, like schema.sql).
- Rebuilt dist/1_cafe24_upload (+ move-detect-cafe24.zip): autoloader resolve
check 28 classes, parse check, all of today''s controllers/assets/schema in.
- Rebuilt dist/2_pi_install with --cross: aarch64/cp311 wheelhouse (11 wheels,
onnxruntime included), HEF verified against the pinned hailo8 digest,
requirements.lock + sbom.json + SHA256SUMS regenerated, ergonomics detector
staged.
- FLAG: the MoveNet ONNX upstream URL 404s, so this Pi bundle is Hailo-only
(no CPU-fallback pose model). fetch_movenet.py needs a new source URL before
a delivery that requires the CPU path.
Same pose stream, new safety axis: musculoskeletal-load prevention. This is an
honest 2D reduction of REBA - band exposure TIME, not a precise REBA score.
Edge:
- geometry: arm_elevation_deg (shoulder->elbow vs vertical-down, best of both
arms) and knee_flexion_deg (hip-knee-ankle interior angle, worst of both
legs). Both return None when no confident side exists - "unmeasurable" must
never read as "0 deg / straight".
- detectors/ergonomics.py: per-frame dt accumulation into bands (trunk 20-60
and 60+ deg via the existing torso angle, arm 90+ deg, knee <110 deg), gated
on confident core keypoints; detection gaps are dt-capped (2s) so a dropout
never counts as exposure. Hourly per-camera report, skipped when nothing was
observed (an empty-workshop report is noise).
- Engine: tracks in an incident state (FALLING/FALLEN/NO_MOTION) are excluded
- a fallen worker's 90-deg trunk is an incident, not an ergonomic exposure.
The report event (ergo_report/INFO) carries no track_id and no spatial:
a camera-level anonymous aggregate, per the PIPA-first product stance.
- run_once: NO snapshots on ergo_report (a frame would turn a statistic into
surveillance imagery); capture is skipped entirely when only ergo fired.
Server/UI: ergo_report accepted (validator + chk_events_type, edited in the
still-undeployed heatmap migration); detail view now renders event metrics
with Korean labels and seconds humanized - the ergo report's whole value IS
its metrics. Korean type label in list + detail.
Verified: pytest 232 pass (10 new) / PHPUnit 135 pass. NOTE: the detail-view
visual check is pending - the Chrome extension disconnected mid-verification;
harness is staged under the session scratchpad (events/1/view).
The feedback loop, per product decision (option a): tags feed STATISTICS only.
A human reads the rate and re-tunes a camera deliberately - the tag never
adjusts edge sensitivity on its own (no silent behavior change in a safety
system).
- POST /events/{id}/false-positive: same RBAC ('acknowledge') + CSRF + site
scope as ack. Tagging also acknowledges (an FP is a handled event - leaving
it unacknowledged would keep it alarming). COALESCE keeps the FIRST tagger:
a second tag never rewrites who made the original audit-relevant call.
- move_events.false_positive_by/at (migration included), audit-logged.
- GET /api/fp-stats?days=N: per-(camera, event_type) totals + FP counts,
FP>0 groups only, fp-desc, fail-closed site scoping.
- Console: each open row gets a visually-secondary 오탐 button beside 확인
(can't be fat-fingered as the default); resolved rows show a mono verdict
("오탐 (user)" struck through vs "확인됨 (user)"). New rail panel "오탐율 ·
30일" lists top offenders - 30%+ highlighted as re-tuning candidates -
refreshed on a slow 60s cadence plus immediately after a tag. Detail view
shows the tag.
Verified: PHPUnit 135 pass / pytest unchanged + Chrome real-render (tag ->
row flips to struck-through verdict + drops out of unacked KPI/tree counts ->
FP panel updates immediately; no console errors).
Heinrich's-law play: the signals we already compute but used to throw away
become a spatial risk map of the workplace, drawn over the camera's own frame.
Edge:
- near_fall (INFO): a track that reached FALLING (sustained prone) but recovered
before the FALLEN confirm. Treated as a full event per product decision -
same ingest, snapshots, dedup, ack flow as incidents. Fix en route: prone_s
used a truthiness check that turned prone_since=0.0 into "no timer".
- Every per-track event (fall/no_motion/near_fall/immobility/zone hits) now
carries the foot point in the zones' normalized 0..1 space (reusing the zone
detector's own coordinate math); ghost's pixel-space loss point is normalized
at emit. to_payload omits the key when absent, so an old server ignores it.
Server:
- move_events.spatial_x/spatial_y (nullable DOUBLE) + validator: optional
[x, y] finite-number pair; range deliberately NOT enforced (an off-by-a-hair
foot must never reject a FALL alert - renderers clamp).
- MIGRATION FIX: chk_events_type would have REJECTED the previous commit's
lone_worker/overcapacity/overstay inserts on MySQL 8.0.16+ - the constraint
is rebuilt with all 13 types (incl. near_fall).
- GET /api/heatmap/{camera}?days=N (1..90, default 30, 5000-point cap):
spatial points with the same fail-closed site scoping as the event list.
UI (zone editor): "위험 히트맵" toggle + day window. Additive radial blots in
an offscreen layer under the zone polygons; severity weighs the alpha so a
cluster of near-misses reads lighter than one confirmed fall at the same spot.
Events console/detail label near_fall in Korean.
Verified: pytest 222 pass / PHPUnit 130 pass + Chrome real-render (blue
near-miss cluster + red incident hotspot inside the restricted zone, amber
cluster in the warning zone, zones drawn on top; no console errors).
Per-zone declarative safety rules, set in the zone editor and enforced on the
edge from the tracking the pipeline already does (no new geometry, no new model):
- min_workers (2인 1조): 0 < count < minimum sustained 30s -> lone_worker/HIGH.
A solo worker in a buddy-rule zone is a compliance violation and a rescue-
delay risk, so it outranks the other two.
- max_workers: count > limit sustained 10s -> overcapacity/WARNING.
- max_dwell_s: one track's continuous stay past the limit -> overstay/WARNING.
A short detection dropout (<=5s grace) does NOT reset the dwell timer -
without that, run_once's stale-track filter would quietly neuter every
10-minute rule at real-world detection rates.
Edge: detectors/occupancy.py consumes Zone.rules; the engine flips the zone
detector's per-track membership into per-zone headcounts (stale tracks already
excluded), so counts always match what was actually detected this tick. Fired
states clear on condition recovery - re-alerts need a fresh violation, not a
cooldown expiry. Malformed rules_json keeps the zone's intrusion detection
alive and logs the rules as inactive (never fail dark).
Server: move_zones.rules_json (nullable JSON, migration included) validated
strictly in ZoneConfigController (bounds, min<=max, unknown keys rejected -
a silently dropped rule is one the operator believes is active). Delivered to
the edge unchanged via GET /api/config. EventValidator accepts the 3 new types.
UI: zone editor gains the three rule fields (dwell entered in minutes, wired
in seconds) and shows each zone's rules in its list row; the events console
and detail view now render every event type in Korean (wire codes fall back).
Verified: pytest 216 pass / PHPUnit 123 pass + real-render harness in Chrome
(editor save payload, list labels, console type labels).
- P2-6 tracking: global greedy IoU matching (all pairs, best overlap first)
replaces track-iteration-order greedy, so a crossing worker's box can no
longer be stolen by whichever track came first. Any track in a contended
match (person overlapping 2 tracks, or track overlapping 2 persons) is
reported via tracker.ambiguous(); run_once resets its detector + smoother
state so FALLEN/baseline/immobility never ride an id swap onto the wrong
person. A genuinely fallen worker re-confirms within prone_confirm_s.
- P2-7 fall: STANDING -> FALLING now requires sustained prone
(falling_confirm_s, 1.0s). One prone-looking frame (a bend, a bbox glitch)
no longer arms the ghost detector's CRITICAL if the person then leaves.
FALLEN / no_motion timing is unchanged (keyed off prone_since).
- P2-8 app: run_once feeds the engine and smoothers only tracks re-detected
this tick; a track coasting inside max_age with a frozen frame is no-data,
so prone-confirm / no-motion / immobility timers cannot accrue on stale
data. Re-detection still confirms the fall (dropout pauses evidence).
TDD: 7 new tests verified failing on the pre-fix code (git stash), then
203 passed / 2 skipped (hardware) with the fixes.
- Rail rework: the flat device strip + camera <select> becomes one device->camera
tree (device-tree.js owns the DOM). A device row carries the heartbeat verdict
(online/offline, fps, temp, throttle, config drift); the cameras seen in the
event stream nest under it and double as the stream's camera filter.
- The camera filter is a (device, camera) PAIR: camera ids are free-form edge
strings, so two devices may both run a "cam-01" - one device's events no longer
leak into another's filtered view. Clicking the selected camera (or 전체 보기)
clears the filter.
- A camera whose device is missing from /api/devices renders as its own
unknown-device group ("장치 목록에 없음 - 등록 확인") instead of being dropped.
- Offline rows show liveness only - fps/temp are the LAST values a dead device
reported and would read as current telemetry.
- Poll-failure staleness, collapse state, and focus survive the 5s re-render;
an unchanged poll never rebuilds the DOM (render signature).
- Stream rows: device NAME + mono camera id + compact 24h stamp (year only when
not current); thumb slot links to the incident and is honest about 무영상 for
screen readers; KPI strip captioned "미확인" once.
Verified: PHPUnit 119 pass (4 skipped, PDO gate) + real-render harness in Chrome
(tree states, pair filter, collapse, reset, no console errors).
Rework the events dashboard from the sparse light-minimal look (which read as
empty / unprofessional) into a professional, information-dense operations
console — keeping the LIGHT direction (the dark HMI was previously rejected).
- App shell: top bar with an "AI SAFETY CCTV" brand mark + product identity, and a
device/camera rail (a second neutral layer) beside the detection stream.
- Density: device health + filters move into the rail; the four hero KPI cards
become a compact inline pill strip; the stream is snapshot-forward.
- Every event row is a camera slot (evidence thumbnail, or a "무영상" placeholder)
so the stream reads as a CCTV grid and rows stay aligned regardless of snapshots.
- Restrained near-monochrome; indigo for interaction only; severity colour spent on
the unacknowledged-critical alarm only; contrast-checked ink; reduced-motion honoured.
- Pure CSS, system fonts, zero external assets (air-gap). DOM contract (ids/classes)
unchanged, so polling/ack/alarm/filter logic is untouched. Verified in-browser.
- Heartbeat reports the CONFIGURED camera count, not just the started ones: a
camera that failed to construct at boot (wrong index / unseated ribbon) now
shows as total/erroring instead of vanishing, so the server can see it is dark.
- GHOST emits without an engine cooldown key (ghost.update already fires once per
never-reused track id) — removes a permanent _last_emit key that forget() could
never reclaim (a slow 24/7 leak).
- Malformed zone rows are logged loudly instead of silently swallowed, matching
the pixel-space branch's "don't fail dark" — a dead zone is no longer discovered
only after a worker enters it.
- server-deploy.md: state MySQL 8.0.13+ / MariaDB 10.2+ is required (expression
defaults), not "5.7+" — an import against 5.7 is a day-1 ERROR 1064 blocker.
- pilot-runbook: add the air-gapped Pi path and mark pip/fetch as build-machine
only; stop the shipped doc from telling a Pi operator the bundle "does not exist".
- install_offline.sh: instruct the camera probe before enable (mirrors setup_online).
- setup_online.sh: chmod 600 device.env on read; README documents delete + rotate.
- systemd unit: order After/Wants=time-sync.target so occurred_at is real wall time.
- Device-status panel (DeviceStatusController): site-scoped liveness over stored
heartbeat telemetry. Fix config_drift false-positive by storing NULL until the
device actually reports a version (was firing "설정 미적용" on every device).
- Zone save rejects a blank/unknown device_id (DeviceRepository::exists) so a zone
is never persisted against a device that will never fetch it.
- Snapshot proxy sends immutable private cache headers, ending the 5s-poll
re-download of every thumbnail (a Cafe24 traffic-cap hazard).
- Event list: paged-in acknowledgements stick; honesty affordances for the paging
gap, a stale device poll, and a filtered camera aging out; logout wired for
every page via the shared layout.
- Split clocks: detector timers/cooldowns run on a MONOTONIC clock (immune to an
NTP step on an RTC-less Pi); wall time is used only for the HMAC X-Timestamp
and Event.occurred_at. Fixes false IMMOBILITY / dropped tracks after a clock jump.
- Heartbeat is time-based (30s, predictable regardless of load) and now carries
temp/throttle/queue_pending/config_version telemetry.
- A dead (no-frame) camera counts as erroring instead of a healthy frame, so a
silent monitoring outage no longer looks green.
- Offline queue: isolate one undecodable row instead of wedging every event behind
it; classify 401 (stale clock) as transient, never a poison-drop.
- New edge/health.py: vcgencmd temp/throttle parsing, None off-Pi (no fake 0).
Drawing hazard zones on a blank canvas meant guessing pixel coordinates.
The editor can now pull the camera's most recent snapshot and draw over
what the camera actually sees.
- SnapshotRepository::latestForCamera() (+ Pdo/in-memory impls), ordered by
snapshot_id, not the device-reported occurred_at (a skewed Pi RTC would
otherwise pin 'latest' to a stale frame)
- GET /api/cameras/{camera}/latest-snapshot -> {uuid}; returns only the id,
the bytes still go through SnapshotProxy's session + site-scope check.
Out-of-scope and missing both 404, so a scoped operator cannot probe for
another site's cameras
- ix_events_camera (camera_id, event_id) so the lookup is not a full scan
- '현재 화면 불러오기' button in the zone editor
Tests: PHP 113 pass (4 skipped, 4 new), pytest 174 pass.
build_runtimes() logs-and-skips a camera it cannot open, so a wrong
/dev/video index or an unseated ribbon produced a green systemd unit
that silently monitored one camera less.
- edge/probe.py: opens each camera in CAMERAS, retries while the sensor
settles, reports frame geometry, optionally saves preview PNGs, and
exits non-zero with a backend-specific hint (v4l2-ctl / rpicam-hello)
- setup_online.sh: new step [8/9] runs it against the installed package
and the CAMERAS line actually written to edge.env, and refuses to
enable the unit on failure. SKIP_CAMERA_CHECK=1 opts out.
Tests: 174 pass, 2 skipped (7 new).
The online Pi install required a four-variable command line typed on the
device. setup_online.sh now reads DEVICE_ID / DEVICE_SECRET / CAFE24_URL /
CAMERAS from deploy/device.env (written on the build machine) so the whole
install is 'sudo deploy/setup_online.sh'.
Real env vars still take precedence, so the documented long-form command
overrides a stale device.env. device.env is gitignored -- it carries the
device HMAC secret in plaintext.
Delivery had two ambiguously-named outputs and always produced an
archive, which made it unclear what actually gets uploaded/copied.
- dist/1_cafe24_upload/ -> FTP-upload contents to the Cafe24 web host
- dist/2_pi_install/ -> copy folder to the Pi, run ./install_offline.sh
Archives are now opt-in: MAKE_ZIP=1 (Cafe24) / MAKE_TARBALL=1 (Pi).
Docs and tests updated to the new paths.
Tests: 165 pass, 2 skipped.
The PHP app runs on Cafe24 shared web hosting -- calling it 'cloud'
was misleading. Renames the directory, the edge client module, and
every doc/script/identifier that used the term.
- cloud/ -> server/ (incl. server/deploy/build_cafe24_package.sh paths)
- edge/cloudclient.py -> edge/serverclient.py, CloudClient -> ServerClient
- cloud/docs/cloud-deploy.md -> server/docs/server-deploy.md
- CLOUD_* env/config keys -> SERVER_*
- tests renamed to match
Tests: PHP 109 pass (4 skipped), pytest 167 pass. dist/ regenerated.
Renames the 10 P1-B tables (users, devices, cameras, zones, events,
event_snapshots, thresholds, nonces, rate_limits, audit_log) to a
move_ prefix so the schema can co-tenant a shared-hosting database.
- schema.sql + all App\Repository\Pdo\* queries + tests + deploy docs
- new migrations/2026-07-20-move-table-prefix.sql: atomic RENAME TABLE
for existing installs; must run BEFORE deploying this code
- index/constraint names left unchanged (no cross-table collision)
Tests: 109 pass, 4 skipped.
On a Windows/Git-Bash build box with no `zip`, the packager fell back to
PowerShell Compress-Archive, which writes BACKSLASH path separators. Cafe24's
extractor (and some unzip tools) do not normalise them, so the app tree unpacked
as flat files (move_detect_app\src\... as a single filename) and the deploy
silently broke — a likely cause of the repeated install failures.
Prefer a Python zipfile step (POSIX arcnames) between `zip` and PowerShell.
Verified: rebuilt zip extracts with a correct move_detect_app/src/... tree and
no "backslashes as path separators" warning.
Two independent adversarial reviews (codex CLI + a 6-dimension verification
workflow) of the delivery-hardening branch surfaced ~40 findings; this closes
the confirmed correctness/security/deploy blockers plus a codex re-review pass.
edge:
- geometry: track_displacement per-second threshold recalibrated (0.02->0.25,
env-tunable via MOTION_THR, validated finite/positive); confidence-filter
keypoints with a bbox-center fallback so hallucinated points don't fake motion
- fall: low-confidence frames no longer cancel an active FALLING/FALLEN incident
(freeze + reset recovery timer so recovery needs a fresh confident streak);
baseline height only seeded from trusted frames
- queue: PostClient.send() returns a tri-state SendResult; only definite 4xx
count toward the poison drop cap, so a network outage (incl. 408/425/429/5xx)
never deletes queued safety events; legacy rows persist their minted uuid
- config: reject placeholder / http / short DEVICE_SECRET at startup
- zones: warn+skip legacy pixel-space polygons that would silently match nothing
cloud:
- zone endpoints wired into P3-2b site-scope (cross-site read/write/delete
closed) + role gate on list; catch narrowed so DB faults surface as 500
- snapshot purge keeps the DB row when unlink() fails (no untrackable PIPA orphan)
- ingest dedup path re-stores missing snapshots (+ short-write guard); UNIQUE
(event_id, content_hash) makes recovery idempotent under retry/race
- event list cursor carries full-precision occurred_at (no float rounding at the
page boundary); string keyset end-to-end
- SiteScope '' fails closed (only NULL is global); dashboard fetches full page cap
- runnable migration (site/event_uuid + backfill ordering + indexes); rate-limit
reap fails open
deploy:
- offline bundle hashes EVERY file (fatal on missing/failed/injected; models in
SBOM; arch in tarball name; REQUIRE_SIGNATURE option)
- installer verifies before any mutation, Hailo arch preflight before install,
atomic src swap, backend reconcile preserves a working operator choice
- systemd DeviceAllow=char-dma_heap (the /dev/dma_heap/ dir form was inert)
Verified: edge pytest 165 passed / 2 skipped; cloud phpunit 109 passed / 4
skipped; deploy scripts bash -n clean. Reviewed by codex twice (findings +
fixes) with the second-pass findings folded in.
Two approved data-model changes.
P1-9 — ingest idempotency. A POST can succeed server-side but time out
client-side; the edge then re-queues and re-sends the same event, so the
cloud stored duplicate alerts. The edge Event now carries a stable
event_uuid (uuid4 hex) generated once at creation and preserved through
the offline queue and every retry. Cloud: events.event_uuid CHAR(32) NULL
UNIQUE; EventValidator accepts/validates it; IngestController returns the
already-stored event (200, dedup:true) on a repeat uuid, race-safe against
the UNIQUE key (catches the insert-race PDOException and returns the
winner).
P3-2b — site-scoped access control. site_scope was stored in the session
but never enforced, so any authenticated user could read every site's
worker snapshots (PIPA). Added devices.site; ingest denormalizes it onto
events.site (server-derived from the authenticated device_id, never
client-supplied). App\Security\SiteScope centralizes the rule (NULL scope
= global operator). The event list filters in SQL (page(...,$site)); event
detail + acknowledge and the snapshot proxy guard per-row and return 404
(not 403) cross-site so existence never leaks.
Schema migration ALTERs and per-device/user provisioning UPDATEs are
documented at the bottom of schema.sql.
Tests: edge queue round-trip keeps the uuid; repeat-uuid dedups to one
row; list scoped to caller site; detail cross-site 404; snapshot
cross-site 404 / same-site 200; event_uuid validation. phpunit 97 passing
(4 skipped: PDO), pytest 158 passing (2 skipped).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
Records the pass that closed P4-5/6/7/8, P5-3/4, P2-9/10/11 and documents
why the remaining 8 are not solo-doable (data-model decisions: P1-9,
P3-2b; on-hardware safety-timing validation: P2-6/7/8).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
Three keypoint-trust hardening fixes that reuse the codebase's existing
"visible" confidence convention (no new tuning knobs):
- P2-9 zone.py: _foot averaged both ankles unconditionally, so a
hallucinated ankle (conf~0, often pinned to (0,0)) dragged the foot
point into or out of a zone. Now averages only ankles with conf >= 0.2
and falls back to the bbox bottom-centre when neither is trustworthy.
- P2-11 geometry.py: head_below_hips used the NOSE regardless of
confidence. Now returns False when the nose is below the visibility
threshold, so an unconfident/hallucinated nose can't fire the
head-below-hips fall cue. The confident-nose path is unchanged
(test_fall.py stays green).
- P2-10 movenet_pose.py: the CPU MoveNet fallback is single-person, so at
a multi-worker site every worker but one is silently invisible to
fall/zone/ghost detection. Construction now logs a loud startup WARNING
pointing to the Hailo backend.
Tests: test_zone (bbox fallback), test_geometry (low-conf nose), and
test_movenet_pose (caplog warning). Full python suite: 157 passed, 2
skipped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
The offline bundle shipped unpinned pip deps with no digests, no SBOM,
and no signature — an air-gap target can't re-resolve from PyPI, so a
truncated or tampered wheel would install silently.
build_offline_bundle.sh now emits, from the resolved wheelhouse:
- SHA256SUMS — sha256 of every wheel + model
- requirements.lock — exact name==version pins for a reproducible rebuild
- sbom.json — CycloneDX 1.5 components (name, version, pypi purl, sha256)
- SHA256SUMS.asc — optional detached GPG signature when SIGN_KEY is set
MANIFEST advertises the integrity set and whether it is signed.
install_offline.sh verifies `sha256sum -c SHA256SUMS` (and the signature,
if present) BEFORE any pip install, hard-failing on mismatch; older
bundles without the file pass with a warning.
The generation logic was verified end-to-end on a synthetic stage:
requirements.lock/SHA256SUMS correct, `sha256sum -c` passes, sbom.json is
valid CycloneDX JSON. Both scripts pass `bash -n`.
Tests (test_deploy_artifacts.py): pinned integrity manifest is emitted
(+ optional SIGN_KEY), installer verifies integrity before install.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
The air-gapped bundle never fetched the Hailo HEF, so an offline Hailo
install had no pose model. Two latent breaks also made the offline path
unusable regardless of P5-3:
- build [2/5] ran `fetch_movenet` unconditionally; with the MoveNet
source unreachable that aborted the whole build under `set -e`.
- install_offline copied only `*.onnx`, so even a HEF placed in the
bundle never reached $PREFIX/models for the backend gate to find.
Fixes:
- build_offline_bundle.sh fetches the $HAILO_ARCH (default hailo8) HEF
via edge.tools.fetch_hailo_hef (pinned sha256, aborts on mismatch) and
stages yolov8s_pose.hef. MoveNet ONNX is now optional (guarded fetch →
Hailo-only bundle if unavailable). onnxruntime moves to a best-effort
optional wheel. MANIFEST lists models + available pose_backends.
- install_offline.sh copies *.hef (and tolerates a missing *.onnx).
Tests (test_deploy_artifacts.py): bundle fetches+stages the HEF, MoveNet
is optional (guarded), installer copies the HEF. Both scripts pass
`bash -n`. Full python suite: 152 passed, 2 skipped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
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
The event list was hard-capped at the newest 100 with no way to reach
older events. Adds keyset pagination:
- EventRepository::page(limit, severity, beforeTs, beforeId) on both the
PDO and in-memory implementations, ordered (occurred_at DESC,
event_id DESC). The cursor is the last row's (occurred_at, event_id),
so pages stay stable as new events arrive at the head — no overlap, no
skips. occurred_at is bound as a string in SQL to keep DECIMAL
precision.
- EventListController reads `limit` (default 50, capped 200) and `cursor`
("occurred_at:event_id", malformed input ignored) and returns
`next_cursor` only on a full page. recent() is retained for existing
callers; the response gains next_cursor additively.
The live-tail dashboard keeps consuming page 1; a history-browse UI can
build on the same cursor. Test: testListPaginatesWithCursor.
phpunit: 89 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
P4-8: AuthController::login now verifies the CSRF token the login form
already emits (Csrf::check over session vs posted field), returning 403
before the user lookup. Closes a login-CSRF / session-fixation hole where
a forged cross-site POST could silently log a victim into an attacker's
account. Tests seed the session+body token; adds
testLoginRejectsMissingOrWrongCsrf.
P4-6: ZoneConfigController::save wraps the repository write in
try/catch(\RuntimeException) so an ownership/device mismatch is a 400
(invalid_zone) client error, not an uncaught 500. Adds
testSaveOwnershipMismatchIs400NotError with a throwing repo double.
phpunit: 88 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
- P4-9 [L] logout() rotates the session id before wiping (was regenerate-after-destroy → warning).
- P4-10 [L] EventValidator caps device_id/camera_id (64) + zone_id (32) → 400 not a MySQL
'Data too long' 500 the edge retries forever.
- P4-11 [L] Request::pathFor collapses leading slashes so '//api/events' isn't misrouted to '/events'.
- P5-6 [L] cafe24 build prunes dev caches (.ruff_cache/.impeccable/__pycache__/…) from the package.
python + phpunit (86) green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
- P5-2 [H] build_offline_bundle.sh native branch hard-fails when uname -m != TARGET_ARCH
(an x86 build was producing an aarch64-named tarball of x86_64 wheels).
- P5-5 [H] systemd unit adds DeviceAllow char-media + /dev/dma_heap for the CSI camera —
picamera2/libcamera on Pi 5 needs them or the unit denies its own camera. OFF-HW: verify on Pi.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
- P3-1 [H] _head_boxes sizes the anonymization region from the person bbox (scales with
distance) + bbox-top fallback when the nose keypoint is low-confidence; a fixed ~40px
patch had left close faces identifiable. test_faceblur.py.
- P3-3 [H] fetch_movenet.py rewritten to stage-validate (reject HTML/LFS-pointer, optional
pinned sha256+size, fail loud) per the SHA256 spec requirement. Discovery: the old PINTO
raw URL is DEAD (GitHub 404 → it was silently saving an HTML page as the .onnx). Non-blocking
for the Hailo delivery path; the CPU MoveNet fallback needs a valid MOVENET_ONNX_URL pinned.
python + phpunit suites green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
Three intertwined bodies of work developed and tested together this session.
Full test suite green: python (edge) + phpunit (cloud).
## Edge/Pi install path
- setup_online.sh: apt-lock retry (apt-daily contention), Type=exec unit +
StartLimitIntervalSec in [Unit], journalctl-on-failure, crash-loop detection.
- onnxruntime is now best-effort (no sdist / no 32-bit-ARM wheel); [6/8] probes the
real backend and hard-fails if none works.
- Hailo HEF auto-fetch: edge/tools/fetch_hailo_hef.py (pinned sha256, refuses to guess
8 vs 8L, HAILO_ARCH override for serial-less boards); hailo_pose.py run() timeout fix.
- install_offline.sh: mirrors the backend gate; onnxruntime optional.
## Cafe24 cloud package (cloud/deploy/)
- build_cafe24_package.sh emits move_detect_app/ (private, one folder) + www/move_detect/
(docroot subdir); no-dev classmap autoloader; preflight.php; hardened .htaccess.
- Subdirectory mount support: Request::baseFrom/pathFor strip the mount prefix; index.php
walks up to find the app root; views/JS prefix every URL with APP_BASE; session cookie
scoped to the mount.
## Delivery audit — silent-failure & correctness (docs/delivery-audit.md)
All 4 blockers + all Phase-1 silent-failure + Phase-2 HIGH correctness:
- Edge cloud clients send a custom User-Agent (cafe24 WAF blocks python-requests) and
allow_redirects=False.
- Daemon fails loud on blank CAFE24_URL/DEVICE_SECRET; run_loop logs swallowed exceptions;
heartbeat carries real fps + per-camera health; UvcSource reopens a dead camera;
offline queue is size-capped and drops poison rows; TARGET_FPS/QUEUE_DB validated.
- Zone coordinates normalized 0..1 on both sides (were canvas-px vs camera-px — every zone
misplaced); pinch zones emit PINCH/HIGH (were downgraded to WARNING).
- Fall detection uses bbox cues when keypoints are low-confidence (occluded fallen person);
ghost incident not cancelled by a bystander; displacement normalized per-second (FPS-invariant).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
setup_online.sh sets up an internet-connected Pi in a single command; the daemon now
skips a camera that fails to construct so an unseated CSI camera never crash-loops the
service. Adversarial review clean after the fix.
deploy/setup_online.sh installs system packages (picamera2, hailo-all best-effort),
builds the venv + pip deps, fetches the MoveNet model, writes /etc/move-detect/edge.env
(prompting or from env vars), installs the systemd unit and starts the service — one
command, no offline bundle. Starts on CPU MoveNet; drop in a HEF for Hailo. numpy left
to the system to avoid shadowing picamera2's build. Air-gapped path (deploy/bundle/*)
unchanged.
build_runtimes() builds each camera's source+runtime in a try/except: a camera that
raises at construction (e.g. an unseated/absent CSI camera whose Picamera2(...) throws)
is logged and skipped so the working cameras still run, instead of an unguarded
exception crash-looping the whole daemon (Restart=always). Raises only if no camera
comes up. Closes the eager-construction gap the review flagged.
CSI + USB (or any mix) at once: one PipelineRuntime per camera with its own
camera_id, sharing the pose adapter (Hailo round-robin), offline queue, and cloud
sync; zones routed per camera. run_loop generalized to N cameras with per-camera
error isolation; single-camera path unchanged. Adversarial review clean.
run_loop now accepts a single runtime or a list; it steps every camera each tick
(one shared clock read), isolates a per-camera step exception, runs the periodic
sync/heartbeat/flush once, and routes zones per camera via a zones_for(cfg, rt)
callback. parse_cameras() reads CAMERAS='backend:device:camera_id,...' (single-camera
fallback when unset) and refuses to yield zero cameras. zones_from_config() gains a
camera_id filter; PipelineRuntime exposes camera_id. Single-camera behavior unchanged.
Implement the Pi5 + Hailo-8 HAT + CSI camera premise: real PiCameraSource
(picamera2), CAMERA_BACKEND=csi|uvc switch, and an offline install that exposes the
Pi system packages (HailoRT, picamera2) to the venv via --system-site-packages, with
Hailo/CSI prerequisites documented. Adversarial review clean.
Create the venv with --system-site-packages so it can import the Pi system packages
that are not pip wheels (hailo_platform via hailo-all, picamera2 via python3-picamera2).
Add CAMERA_BACKEND/CSI_DEVICE to the env template and document the Hailo-8 + CSI
prerequisites and air-gap handling in deploy/README.
Replace the PiCameraSource NotImplementedError stub with a working picamera2 CSI
source (SourcePort read()->(ok,frame), RGB888->BGR to match the pose adapters;
picamera2 lazy-imported as a system package, injected fake in tests). daemon.main()
gains CAMERA_BACKEND (csi|uvc) selecting PiCameraSource or UvcSource.
(a) Offline-installable bundle (wheelhouse + model + source) + systemd unit for
the air-gapped Pi 5 edge daemon (--no-index install, zero network fetches).
(b) Real HailoRT yolov8s_pose adapter (host DFL box + keypoint decode + NMS,
COCO-17), replacing the stub, selectable via POSE_BACKEND; device-validated on
bringup. Adversarial review clean on the decode/API; three confirmed deploy-config
defects (systemd inline-comment crash, non-idempotent install, unpinned wheel ABI)
fixed.