-- Add fenced reconciliation claims and durable, secret-free MediaMTX path -- ownership/orphan reports. Runtime migrations are installed out of process. ALTER TABLE sense.reconcile_state ADD COLUMN IF NOT EXISTS lease_owner text, ADD COLUMN IF NOT EXISTS lease_token text, ADD COLUMN IF NOT EXISTS lease_until timestamptz; DO $constraints$ BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_constraint WHERE conrelid = 'sense.reconcile_state'::regclass AND conname = 'sense_reconcile_lease_all_or_none' ) THEN ALTER TABLE sense.reconcile_state ADD CONSTRAINT sense_reconcile_lease_all_or_none CHECK ( (lease_owner IS NULL AND lease_token IS NULL AND lease_until IS NULL) OR (btrim(lease_owner) <> '' AND btrim(lease_token) <> '' AND lease_until IS NOT NULL) ); END IF; END $constraints$; CREATE INDEX IF NOT EXISTS sense_reconcile_lease_due_idx ON sense.reconcile_state(lease_until, next_attempt_at); CREATE TABLE IF NOT EXISTS sense.media_path_ownership ( path_name text PRIMARY KEY, device_id text NOT NULL, tenant_id text NOT NULL, site_id text NOT NULL, first_claimed_at timestamptz NOT NULL, last_confirmed_at timestamptz NOT NULL, CONSTRAINT sense_media_path_ownership_not_blank CHECK ( btrim(path_name) <> '' AND btrim(device_id) <> '' AND btrim(tenant_id) <> '' AND btrim(site_id) <> '' ) ); ALTER TABLE sense.media_path_ownership OWNER TO sense_app; -- Keep the historical row independent from device lifecycle so a later device -- deletion cannot erase the evidence needed to classify an owned stale path. INSERT INTO sense.media_path_ownership( path_name, device_id, tenant_id, site_id, first_claimed_at, last_confirmed_at ) SELECT d.path_name, d.id, d.tenant_id, d.site_id, d.created_at, d.updated_at FROM sense.devices d WHERE btrim(d.path_name) <> '' AND EXISTS ( SELECT 1 FROM sense.device_capabilities c WHERE c.device_id = d.id AND c.capability = 'video_capture' ) ON CONFLICT (path_name) DO UPDATE SET device_id = EXCLUDED.device_id, tenant_id = EXCLUDED.tenant_id, site_id = EXCLUDED.site_id, last_confirmed_at = GREATEST( sense.media_path_ownership.last_confirmed_at, EXCLUDED.last_confirmed_at ); CREATE TABLE IF NOT EXISTS sense.operational_leases ( lease_name text PRIMARY KEY, owner_id text NOT NULL, fencing_token text NOT NULL, lease_until timestamptz NOT NULL, updated_at timestamptz NOT NULL, CONSTRAINT sense_operational_lease_not_blank CHECK ( btrim(lease_name) <> '' AND btrim(owner_id) <> '' AND btrim(fencing_token) <> '' ) ); ALTER TABLE sense.operational_leases OWNER TO sense_app; CREATE TABLE IF NOT EXISTS sense.orphan_scan_runs ( id text PRIMARY KEY, instance_id text NOT NULL, observed_count integer NOT NULL, owned_stale_count integer NOT NULL, unowned_count integer NOT NULL, safety_allowed boolean NOT NULL, safety_reason text NOT NULL, completed_at timestamptz NOT NULL, expires_at timestamptz NOT NULL, CONSTRAINT sense_orphan_scan_id CHECK (id ~ '^scan_[0-9A-HJKMNP-TV-Z]{26}$'), CONSTRAINT sense_orphan_scan_counts CHECK ( observed_count >= 0 AND owned_stale_count >= 0 AND unowned_count >= 0 AND owned_stale_count + unowned_count <= observed_count ), CONSTRAINT sense_orphan_scan_not_blank CHECK ( btrim(instance_id) <> '' AND btrim(safety_reason) <> '' ), CONSTRAINT sense_orphan_scan_expiry CHECK (expires_at > completed_at) ); ALTER TABLE sense.orphan_scan_runs OWNER TO sense_app; CREATE TABLE IF NOT EXISTS sense.orphan_scan_findings ( scan_id text NOT NULL REFERENCES sense.orphan_scan_runs(id) ON DELETE CASCADE, path_name text NOT NULL, classification text NOT NULL, device_id text, PRIMARY KEY (scan_id, path_name), UNIQUE (scan_id, path_name, classification), CONSTRAINT sense_orphan_finding_classification CHECK ( classification IN ('owned_stale', 'unowned') ), CONSTRAINT sense_orphan_finding_not_blank CHECK ( btrim(path_name) <> '' AND (device_id IS NULL OR btrim(device_id) <> '') ), CONSTRAINT sense_orphan_finding_owner_shape CHECK ( (classification = 'owned_stale' AND device_id IS NOT NULL) OR (classification = 'unowned' AND device_id IS NULL) ) ); ALTER TABLE sense.orphan_scan_findings OWNER TO sense_app; CREATE TABLE IF NOT EXISTS sense.orphan_cleanup_actions ( scan_id text NOT NULL REFERENCES sense.orphan_scan_runs(id), path_name text NOT NULL, classification text NOT NULL DEFAULT 'owned_stale', actor_id text NOT NULL, status text NOT NULL, error_code text, attempted_at timestamptz NOT NULL, PRIMARY KEY (scan_id, path_name), FOREIGN KEY (scan_id, path_name, classification) REFERENCES sense.orphan_scan_findings(scan_id, path_name, classification), CONSTRAINT sense_orphan_cleanup_owned_only CHECK (classification = 'owned_stale'), CONSTRAINT sense_orphan_cleanup_status CHECK (status IN ('deleted', 'failed')), CONSTRAINT sense_orphan_cleanup_not_blank CHECK ( btrim(path_name) <> '' AND btrim(actor_id) <> '' AND (error_code IS NULL OR btrim(error_code) <> '') ), CONSTRAINT sense_orphan_cleanup_error_shape CHECK ( (status = 'deleted' AND error_code IS NULL) OR (status = 'failed' AND error_code IS NOT NULL) ) ); ALTER TABLE sense.orphan_cleanup_actions OWNER TO sense_app; CREATE INDEX IF NOT EXISTS sense_orphan_scan_completed_idx ON sense.orphan_scan_runs(completed_at); CREATE INDEX IF NOT EXISTS sense_orphan_cleanup_status_idx ON sense.orphan_cleanup_actions(scan_id, status); INSERT INTO sense.schema_migrations(version) VALUES (5) ON CONFLICT (version) DO NOTHING;