-- cloud/schema.sql -- MySQL 8.0+ DDL for the P1-B cloud ingest + dashboard subset -- (backing store for App\Repository\Pdo\* -- see cloud/src/Repository/Pdo/). -- -- Scope: this is the P1-B subset of the full design-spec schema (design doc §7.4). -- Tables/columns that belong to later phases are intentionally OMITTED here (YAGNI): -- sites, escalations, oncall, compliance, retention_policy, audit hash-chaining -- (prev_hash/hash on audit_log), and events.disposition/is_test/legal_hold/lifecycle_state. -- See docs/superpowers/specs/2026-07-11-workplace-safety-ai-cctv-design.md §7.4 and the -- Task 16 brief self-review for the P1-B / P2 / P3 split. -- -- sqlite compatibility notes (cloud/tests/PdoIntegrationTest.php uses its own minimal -- sqlite-flavored DDL for the tables it round-trips -- this file targets MySQL only, -- these notes exist to explain the mechanical translation if that ever needs to grow): -- * `INT ... AUTO_INCREMENT` -> sqlite: `INTEGER PRIMARY KEY AUTOINCREMENT` (must be the -- table's sole INTEGER PRIMARY KEY column) -- * `ENGINE=InnoDB` -> sqlite: omit (no storage engines) -- * `DEFAULT CHARSET=...` -> sqlite: omit -- * `JSON` column type -> sqlite: `TEXT` (no native JSON type; app always -- reads/writes JSON-encoded strings either way) -- * `DEFAULT (UNIX_TIMESTAMP())` -> sqlite: `DEFAULT (strftime('%s','now'))` -- * `INSERT ... ON DUPLICATE KEY UPDATE` (rate_limits upsert) -> sqlite: -- `INSERT ... ON CONFLICT(rate_key, window_start) -- DO UPDATE SET count = count + 1` -- * `CHECK (...)` on enum-like columns -> sqlite supports CHECK natively, no change -- * `TINYINT(1)` -> sqlite: `INTEGER` (0/1) -- * `BIGINT UNSIGNED` -> sqlite: `INTEGER` (sqlite ints are always 64-bit signed) -- -- All app-level SQL against this schema uses prepared statements with `?` placeholders -- -- no string interpolation of values anywhere in App\Repository\Pdo\*. SET NAMES utf8mb4; -- --------------------------------------------------------------------------- -- users: dashboard accounts (auth + RBAC + brute-force lockout). -- No PdoUserRepository::create() exists in P1-B -- the first admin account is -- provisioned out-of-band via the CLI snippet in cloud/docs/cloud-deploy.md. -- --------------------------------------------------------------------------- CREATE TABLE move_users ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, username VARCHAR(190) NOT NULL, password_hash VARCHAR(255) NOT NULL, -- Argon2id, see App\Security\Password::hash() role VARCHAR(32) NOT NULL, -- app-whitelisted: admin|operator|viewer (App\Auth\Rbac) site_scope VARCHAR(190) NULL, failed_attempts INT UNSIGNED NOT NULL DEFAULT 0, locked_until BIGINT UNSIGNED NOT NULL DEFAULT 0, -- unix timestamp; 0 = not locked created_at BIGINT UNSIGNED NOT NULL DEFAULT (UNIX_TIMESTAMP()), PRIMARY KEY (id), UNIQUE KEY uq_users_username (username), CONSTRAINT chk_users_role CHECK (role IN ('admin','operator','viewer')) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- --------------------------------------------------------------------------- -- devices: one row per Pi/edge device; holds the per-device HMAC ingest secret. -- No PdoDeviceRepository::create() exists -- rows are provisioned by an admin -- (per-device secret issuance) before the device is allowed to sync/ingest. -- --------------------------------------------------------------------------- CREATE TABLE move_devices ( device_id VARCHAR(64) NOT NULL, name VARCHAR(190) NULL, site VARCHAR(64) NULL, -- access-control scope; matches users.site_scope (NULL = unassigned) secret VARCHAR(255) NOT NULL, -- per-device HMAC key; rotate via admin CLI, never log config_version INT UNSIGNED NOT NULL DEFAULT 1, -- authoritative version served by GET /api/config last_fps DOUBLE NULL, last_temp DOUBLE NULL, last_throttle TINYINT(1) NULL, reported_config_version INT UNSIGNED NULL, -- version the device last claimed via heartbeat last_heartbeat_at BIGINT UNSIGNED NULL, created_at BIGINT UNSIGNED NOT NULL DEFAULT (UNIX_TIMESTAMP()), PRIMARY KEY (device_id), KEY ix_devices_site (site) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- --------------------------------------------------------------------------- -- cameras: per-device camera inventory (design doc §7.4). No dedicated -- PdoCameraRepository exists in P1-B -- zones/events reference camera_id -- directly as a free-form string. Kept for documentation/future per-camera -- config and to avoid a breaking migration when that repository is added. -- --------------------------------------------------------------------------- CREATE TABLE move_cameras ( camera_id VARCHAR(64) NOT NULL, device_id VARCHAR(64) NOT NULL, name VARCHAR(190) NULL, role VARCHAR(32) NULL, -- app-whitelisted: fall_critical|zone|low created_at BIGINT UNSIGNED NOT NULL DEFAULT (UNIX_TIMESTAMP()), PRIMARY KEY (camera_id), KEY ix_cameras_device (device_id), CONSTRAINT chk_cameras_role CHECK (role IS NULL OR role IN ('fall_critical','zone','low')) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- --------------------------------------------------------------------------- -- zones: polygon regions per camera, edited via the zone-config dashboard. -- No FK to cameras/devices: zones may legitimately be created before a -- `cameras` row exists for a given camera_id (no camera-registration flow in -- P1-B), so a FK here would spuriously reject valid app writes. -- --------------------------------------------------------------------------- -- --------------------------------------------------------------------------- -- Improvement action log (개선 조치): a site fix ("미끄럼 매트 설치") recorded against a -- camera; the report compares detection density before/after to prove the fix worked. -- --------------------------------------------------------------------------- CREATE TABLE move_actions ( action_id INT UNSIGNED NOT NULL AUTO_INCREMENT, camera_id VARCHAR(64) NOT NULL, site VARCHAR(64) NULL, -- access-control scope, resolved from the camera's events at insert event_id INT UNSIGNED NULL, -- 이 조치의 계기가 된 사건 (상세 화면에서 바로 기록할 때) note VARCHAR(500) NOT NULL, taken_at BIGINT UNSIGNED NOT NULL, -- when the fix went in (epoch s) created_by VARCHAR(190) NOT NULL, created_at BIGINT UNSIGNED NOT NULL, PRIMARY KEY (action_id), KEY ix_actions_taken (taken_at), KEY ix_actions_site_taken (site, taken_at), KEY ix_actions_event (event_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE move_zones ( zone_id CHAR(32) NOT NULL, -- bin2hex(random_bytes(16)), same convention as snapshot uuids device_id VARCHAR(64) NOT NULL, camera_id VARCHAR(64) NOT NULL, level VARCHAR(16) NOT NULL, -- app-whitelisted: warning|restricted|pinch (ZoneConfigController::LEVELS) polygon_json JSON NOT NULL, rules_json JSON NULL, -- optional occupancy rules: {"min_workers","max_workers","max_dwell_s"} (ZoneConfigController validates) updated_at BIGINT UNSIGNED NOT NULL, PRIMARY KEY (zone_id), KEY ix_zones_camera (camera_id), KEY ix_zones_device (device_id), CONSTRAINT chk_zones_level CHECK (level IN ('warning','restricted','pinch')) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- --------------------------------------------------------------------------- -- events: ingested detections. event_type/severity are already validated in -- App\Ingest\EventValidator before insert(); the CHECK constraints below are -- defense-in-depth only, not the primary validation layer. -- --------------------------------------------------------------------------- CREATE TABLE move_events ( event_id INT UNSIGNED NOT NULL AUTO_INCREMENT, event_uuid CHAR(32) NULL, -- edge-generated idempotency key (dedup a re-sent event); NULL for legacy rows device_id VARCHAR(64) NOT NULL, site VARCHAR(64) NULL, -- denormalized from move_devices.site at ingest; access-control scope camera_id VARCHAR(64) NOT NULL, event_type VARCHAR(32) NOT NULL, severity VARCHAR(16) NOT NULL, track_id INT NULL, zone_id CHAR(32) NULL, occurred_at DOUBLE NOT NULL, -- device-reported epoch seconds (float) duration_s DOUBLE NOT NULL DEFAULT 0, metrics_json JSON NULL, spatial_x DOUBLE NULL, -- normalized foot/reference point (0..1 of the frame), spatial_y DOUBLE NULL, -- heatmap raw material; NULL for legacy/deviceless events acknowledged_by VARCHAR(190) NULL, acknowledged_at BIGINT UNSIGNED NULL, false_positive_by VARCHAR(190) NULL, -- operator's 오탐 tag (per-camera FP-rate stats; no auto-tuning) false_positive_at BIGINT UNSIGNED NULL, -- 증거 보존 보류. 이 시각까지는 보존기간 정리가 이 사건의 스냅샷을 지우지 않는다. -- 중대재해 조사는 몇 주~몇 달인데 스냅샷 기본 보존은 7일이라, 보류가 없으면 "사고 후 -- 원인 분석"이라는 목적 자체가 8일째에 사라진다. 자동 연장이 아니라 명시적 조작으로만 -- 걸리고(자동이면 결국 전량 보존이 되어 프라이버시 약속이 무너진다) 감사에 남는다. hold_until BIGINT UNSIGNED NULL, received_at BIGINT UNSIGNED NOT NULL, -- server receipt time (PHP time()), for clock-skew flagging (design doc §8) PRIMARY KEY (event_id), -- Idempotency: a UNIQUE key on event_uuid makes a re-sent event (POST succeeded server-side -- but the ACK timed out client-side) collide instead of duplicating. MySQL allows multiple -- NULLs in a UNIQUE index, so legacy rows without a uuid do not conflict. UNIQUE KEY uq_events_uuid (event_uuid), KEY ix_events_occurred_at (occurred_at), KEY ix_events_severity_occurred_at (severity, occurred_at), KEY ix_events_site_occurred_at (site, occurred_at), -- site-scoped dashboard list (P3-2b) KEY ix_events_hold (hold_until), -- 보존 정리가 보류된 건만 빠르게 걸러낸다 KEY ix_events_device (device_id), KEY ix_events_camera (camera_id, event_id), -- zone editor: newest snapshot per camera CONSTRAINT chk_events_type CHECK (event_type IN ( 'fall','fall_from_height','no_motion','immobility','ghost','zone_warning', 'zone_restricted','pinch','monitoring_degraded','device_fault', 'lone_worker','overcapacity','overstay','near_fall','ergo_report', -- 감시 자체의 건전성: 카메라가 틀어지거나 가려지면 존 판정이 조용히 무의미해진다. 'camera_moved','camera_obstructed','low_light', -- 아무도 확인하지 않았다는 사실 자체를 사건으로 만든다 (에스컬레이션 디스패처가 발행). 'unacked_escalation','multi_down', -- 모델 확보 후 활성화되는 자리. 엣지 감지기는 있으나 기본 비활성이다. 'ppe_missing','proximity','fire_smoke' )), CONSTRAINT chk_events_severity CHECK (severity IN ('critical','high','warning','info')) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- --------------------------------------------------------------------------- -- event_snapshots: re-encoded JPEG references. Files live under storage/ -- (webroot-adjacent, served only via the authenticated SnapshotProxy). FK to -- events is safe because IngestController always inserts the parent event row -- before calling SnapshotRepository::add() for its snapshots. -- --------------------------------------------------------------------------- CREATE TABLE move_event_snapshots ( snapshot_id INT UNSIGNED NOT NULL AUTO_INCREMENT, event_id INT UNSIGNED NOT NULL, file_uuid CHAR(32) NOT NULL, -- bin2hex(random_bytes(16)); on-disk filename = .jpg content_hash CHAR(64) NOT NULL, -- sha256 of the re-encoded bytes created_at BIGINT UNSIGNED NOT NULL, PRIMARY KEY (snapshot_id), UNIQUE KEY uq_event_snapshots_uuid (file_uuid), -- Idempotent snapshot recovery: a re-sent event carries the SAME re-encoded bytes (same -- content_hash), so this makes a duplicate/racing store collide instead of writing a second -- copy. Lets the dedup path safely re-store missing evidence without duplication. UNIQUE KEY uq_event_snapshots_event_hash (event_id, content_hash), KEY ix_event_snapshots_event (event_id), KEY ix_event_snapshots_created (created_at), -- retention purge scans WHERE created_at < ? CONSTRAINT fk_event_snapshots_event FOREIGN KEY (event_id) REFERENCES move_events (event_id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- --------------------------------------------------------------------------- -- thresholds: per-device (optionally per-camera/zone) tunables synced via -- GET /api/config. No PdoThresholdRepository write method exists in P1-B -- -- rows are seeded/edited directly (phpMyAdmin) until a management UI lands. -- --------------------------------------------------------------------------- CREATE TABLE move_thresholds ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, device_id VARCHAR(64) NOT NULL, camera_id VARCHAR(64) NULL, zone_id CHAR(32) NULL, `key` VARCHAR(64) NOT NULL, `value` VARCHAR(255) NOT NULL, updated_by VARCHAR(190) NULL, -- 편집 UI(관리자 전용)가 남기는 감사 흔적 updated_at BIGINT UNSIGNED NULL, PRIMARY KEY (id), KEY ix_thresholds_device (device_id), UNIQUE KEY uq_thresholds_scope_key (device_id, camera_id, zone_id, `key`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- --------------------------------------------------------------------------- -- nonces: replay-protection store for ingest/config/heartbeat HMAC requests. -- `nonce` is the PRIMARY KEY (unique) so PdoNonceRepository::remember() is -- safe against a duplicate-insert race: IngestController already calls -- seen() before remember(), and a thrown duplicate-key error on a racing -- remember() is an acceptable, expected outcome (closes the seen()/remember() -- TOCTOU flagged in the Task 7 review -- the PK uniqueness is the real guard, -- not the earlier seen() check). -- --------------------------------------------------------------------------- CREATE TABLE move_nonces ( nonce VARCHAR(190) NOT NULL, expires_at BIGINT UNSIGNED NOT NULL, PRIMARY KEY (nonce), KEY ix_nonces_expires_at (expires_at) -- for a periodic `DELETE FROM move_nonces WHERE expires_at < ?` cleanup job ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- --------------------------------------------------------------------------- -- rate_limits: per-key sliding-window counters for ingest rate limiting. The -- (rate_key, window_start) primary key backs an atomic -- `INSERT ... ON DUPLICATE KEY UPDATE count = count + 1` upsert in -- PdoRateLimiterRepository::hit() -- no read-modify-write race window. -- --------------------------------------------------------------------------- CREATE TABLE move_rate_limits ( rate_key VARCHAR(190) NOT NULL, window_start BIGINT UNSIGNED NOT NULL, -- floor(now / 60): the minute bucket count INT UNSIGNED NOT NULL DEFAULT 0, PRIMARY KEY (rate_key, window_start), -- The reap `DELETE ... WHERE window_start < ?` cannot use the PK (window_start is its trailing -- column), so give it its own index to avoid a locking full-scan on the pre-auth hot path. KEY ix_rate_limits_window (window_start) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- --------------------------------------------------------------------------- -- audit_log: append-only action log (acknowledge / zone save+delete / login / -- threshold edit / suppression / handover). -- -- TAMPER-EVIDENT: each row carries prev_hash (the previous row's entry_hash) and -- entry_hash = sha256(id|actor|action|target|before|after|ts|prev_hash). Editing or -- deleting a row breaks every hash after it, so a doctored log is DETECTABLE even -- though shared hosting cannot grant append-only rights at the DB level. Verified by -- `php tools/verify_audit_chain.php`. Rows written before the chain existed keep NULL -- hashes and the verifier reports them as the pre-chain segment rather than as damage. -- --------------------------------------------------------------------------- CREATE TABLE move_audit_log ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, actor VARCHAR(190) NOT NULL, action VARCHAR(64) NOT NULL, target VARCHAR(190) NOT NULL, before_json JSON NOT NULL, after_json JSON NOT NULL, ts BIGINT UNSIGNED NOT NULL, prev_hash CHAR(64) NULL, entry_hash CHAR(64) NULL, PRIMARY KEY (id), KEY ix_audit_log_ts (ts), KEY ix_audit_log_actor (actor), KEY ix_audit_log_entry_hash (entry_hash) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- --------------------------------------------------------------------------- -- suppressions: planned-work alarm suppression (정비 모드 / 작업허가). -- Detection is NOT paused -- events are still ingested and still appear in the console -- and in reports. Only NOTIFICATION is withheld, and every suppression carries a -- mandatory reason, a hard expiry (no open-ended suppression) and an audit entry. -- Rationale: the single largest source of false alarms is planned maintenance, and a -- console that cries wolf during every shutdown is a console the site switches off. -- --------------------------------------------------------------------------- CREATE TABLE move_suppressions ( suppression_id INT UNSIGNED NOT NULL AUTO_INCREMENT, scope_type VARCHAR(16) NOT NULL, -- device | camera | zone scope_id VARCHAR(64) NOT NULL, site VARCHAR(64) NULL, reason VARCHAR(500) NOT NULL, starts_at BIGINT UNSIGNED NOT NULL, ends_at BIGINT UNSIGNED NOT NULL, created_by VARCHAR(190) NOT NULL, created_at BIGINT UNSIGNED NOT NULL, revoked_by VARCHAR(190) NULL, revoked_at BIGINT UNSIGNED NULL, PRIMARY KEY (suppression_id), KEY ix_suppressions_window (ends_at, starts_at), KEY ix_suppressions_scope (scope_type, scope_id), KEY ix_suppressions_site (site), CONSTRAINT chk_suppressions_scope CHECK (scope_type IN ('device','camera','zone')) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- --------------------------------------------------------------------------- -- alert_channels / alert_log: how a detection reaches a human. -- Air-gapped sites cannot use FCM/Slack, so the channels are the ones a closed network -- actually has: `local` (the edge node's own beacon/buzzer over GPIO -- the only path -- that needs no network at all), `email` (in-house SMTP relay) and `webhook` (in-house -- groupware / SMS gateway). step>0 channels fire only after escalate_after_s of the -- event still being unacknowledged, which is what turns "we detected it" into "someone -- was made to look at it". -- --------------------------------------------------------------------------- CREATE TABLE move_alert_channels ( channel_id INT UNSIGNED NOT NULL AUTO_INCREMENT, site VARCHAR(64) NULL, kind VARCHAR(16) NOT NULL, name VARCHAR(190) NOT NULL, config_json JSON NOT NULL, -- recipients / url / gpio pin. NEVER credentials. min_severity VARCHAR(16) NOT NULL DEFAULT 'high', enabled TINYINT(1) NOT NULL DEFAULT 1, step INT UNSIGNED NOT NULL DEFAULT 0, escalate_after_s INT UNSIGNED NOT NULL DEFAULT 0, created_at BIGINT UNSIGNED NOT NULL, PRIMARY KEY (channel_id), KEY ix_alert_channels_site (site, enabled), CONSTRAINT chk_alert_channels_kind CHECK (kind IN ('local','email','webhook')), CONSTRAINT chk_alert_channels_sev CHECK (min_severity IN ('critical','high','warning','info')) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE move_alert_log ( alert_id INT UNSIGNED NOT NULL AUTO_INCREMENT, event_id INT UNSIGNED NOT NULL, channel_id INT UNSIGNED NOT NULL, status VARCHAR(16) NOT NULL, attempts INT UNSIGNED NOT NULL DEFAULT 0, last_error VARCHAR(500) NULL, queued_at BIGINT UNSIGNED NOT NULL, sent_at BIGINT UNSIGNED NULL, PRIMARY KEY (alert_id), -- The dispatcher runs from cron and overlapping runs are expected; this unique key is -- the only thing standing between a site and the same fall being phoned in five times. UNIQUE KEY uq_alert_log_event_channel (event_id, channel_id), KEY ix_alert_log_status (status, queued_at), CONSTRAINT chk_alert_log_status CHECK (status IN ('pending','sent','failed','suppressed')), CONSTRAINT fk_alert_log_event FOREIGN KEY (event_id) REFERENCES move_events (event_id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- --------------------------------------------------------------------------- -- shift_handovers: who took responsibility for what, and when. -- The 24h brief tells an operator what happened; it does not record that anyone -- accepted the backlog. Without that, unacknowledged events evaporate across a shift -- change with nobody accountable for them. -- --------------------------------------------------------------------------- CREATE TABLE move_shift_handovers ( handover_id INT UNSIGNED NOT NULL AUTO_INCREMENT, site VARCHAR(64) NULL, from_user VARCHAR(190) NULL, to_user VARCHAR(190) NOT NULL, period_start BIGINT UNSIGNED NOT NULL, period_end BIGINT UNSIGNED NOT NULL, unacked_count INT UNSIGNED NOT NULL, severe_count INT UNSIGNED NOT NULL, note VARCHAR(1000) NULL, created_at BIGINT UNSIGNED NOT NULL, PRIMARY KEY (handover_id), KEY ix_handovers_site_created (site, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- --------------------------------------------------------------------------- -- risk_controls: 위험성평가 연계. One row per (site, detection type): the hazard it -- stands for, the control currently in place, and the 5x5 rating. This is what lets the -- system print "what are we controlling and how well", which is the artefact a safety -- management system is actually audited on. -- --------------------------------------------------------------------------- CREATE TABLE move_risk_controls ( control_id INT UNSIGNED NOT NULL AUTO_INCREMENT, site VARCHAR(64) NULL, event_type VARCHAR(32) NOT NULL, hazard VARCHAR(300) NOT NULL, control VARCHAR(500) NOT NULL, severity_rating TINYINT UNSIGNED NOT NULL, likelihood_rating TINYINT UNSIGNED NOT NULL, reviewed_by VARCHAR(190) NULL, reviewed_at BIGINT UNSIGNED NULL, updated_at BIGINT UNSIGNED NOT NULL, PRIMARY KEY (control_id), UNIQUE KEY uq_risk_controls_site_type (site, event_type), CONSTRAINT chk_risk_sev CHECK (severity_rating BETWEEN 1 AND 5), CONSTRAINT chk_risk_like CHECK (likelihood_rating BETWEEN 1 AND 5) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- --------------------------------------------------------------------------- -- device_metrics: heartbeat telemetry as a TIME SERIES. -- move_devices holds only the latest value, so a node whose inference latency doubled -- overnight looks identical to a healthy one. Appending each heartbeat is what makes -- slow degradation visible before it becomes a monitoring outage. -- --------------------------------------------------------------------------- CREATE TABLE move_device_metrics ( metric_id INT UNSIGNED NOT NULL AUTO_INCREMENT, device_id VARCHAR(64) NOT NULL, ts BIGINT UNSIGNED NOT NULL, fps DOUBLE NULL, temp DOUBLE NULL, throttle TINYINT(1) NULL, queue_pending INT UNSIGNED NULL, cameras_total INT UNSIGNED NULL, cameras_erroring INT UNSIGNED NULL, errors INT UNSIGNED NULL, PRIMARY KEY (metric_id), KEY ix_device_metrics_device_ts (device_id, ts) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 감시자의 생존 기록. -- -- 죽은 노드는 스스로 신고할 수 없어서 크론(tools/dispatch_alerts.php)이 하트비트의 부재로 -- 판정한다. 그런데 그 크론이 멈추면 아무도 판정하지 않는다 — 통보·5분 확대·장치 이상 발행이 -- 전부 그 한 프로세스에 걸려 있어서, 멈춘 순간부터 화면은 조용해지고 그 침묵은 "사고 없음"과 -- 구별되지 않는다. 감시자의 생존은 감시자가 아닌 쪽이 읽어야 하므로, 크론이 매 실행마다 자기 -- 시각을 여기 남기고 대시보드(/api/devices)가 그 나이를 본다. -- -- 범용 키-값이지만 용도를 넓히지 말 것: 설정은 config.php, 임계값은 move_thresholds 가 있다. -- 여기 들어갈 것은 "프로세스가 언제 살아 있었는가" 뿐이다. CREATE TABLE move_app_state ( k VARCHAR(64) NOT NULL, v VARCHAR(255) NOT NULL, updated_at BIGINT UNSIGNED NOT NULL, PRIMARY KEY (k) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- =========================================================================== -- UPGRADING AN EXISTING DATABASE (fresh installs already have everything above): -- do NOT copy the statements below by hand — they are SQL comments and will not -- run. Apply the RUNNABLE migration instead: -- -- cloud/migrations/2026-07-17-p1-9-p3-2b.sql -- cloud/migrations/2026-07-20-move-table-prefix.sql (run this one LAST) -- -- The second migration renames every table to the `move_` prefix used above and -- by App\Repository\Pdo\* -- an existing DB must be renamed before deploy or all -- queries fail with "table doesn't exist". -- -- It adds devices.site / events.site / events.event_uuid, the retention and -- rate-limit reap indexes, AND backfills events.site from move_devices.site (without -- that backfill, historical events vanish for a scoped operator). Run it once in -- phpMyAdmin BEFORE deploying code that reads these columns, then provision: -- UPDATE move_devices SET site = 'factory-a' WHERE device_id = 'pi-1'; -- UPDATE move_users SET site_scope = 'factory-a' WHERE username = 'operator-a'; -- A user with site_scope = NULL is a global operator; '' is NOT global (matches -- nothing — a blank scope fails closed). -- ===========================================================================