-- Bell v6 append-only rule evaluation, Alert identity and state transitions. CREATE TABLE IF NOT EXISTS bell.rule_versions ( id text PRIMARY KEY, tenant_id bigint NOT NULL, site_id bigint, rule_key text NOT NULL, version integer NOT NULL, display_name text NOT NULL, event_kind text NOT NULL, minimum_severity text NOT NULL, enabled boolean NOT NULL, effective_from timestamptz NOT NULL, config_hash bytea NOT NULL, created_at timestamptz NOT NULL DEFAULT clock_timestamp(), CONSTRAINT bell_rule_version_id CHECK (id ~ '^ruv_[0-9A-HJKMNP-TV-Z]{26}$'), CONSTRAINT bell_rule_version_scope CHECK (tenant_id >= 1 AND (site_id IS NULL OR site_id >= 1)), CONSTRAINT bell_rule_version_key CHECK (rule_key ~ '^[a-z][a-z0-9_-]{2,63}$'), CONSTRAINT bell_rule_version_number CHECK (version >= 1), CONSTRAINT bell_rule_version_name CHECK (char_length(btrim(display_name)) BETWEEN 1 AND 120), CONSTRAINT bell_rule_version_kind CHECK (event_kind ~ '^[a-z][a-z0-9_]{2,63}$'), CONSTRAINT bell_rule_version_severity CHECK (minimum_severity IN ('low','medium','high','critical')), CONSTRAINT bell_rule_version_hash CHECK (octet_length(config_hash) = 32), UNIQUE (tenant_id, rule_key, version), UNIQUE (tenant_id, rule_key, config_hash) ); ALTER TABLE bell.rule_versions OWNER TO bell_app; CREATE TABLE IF NOT EXISTS bell.event_rule_sweeps ( event_id text PRIMARY KEY REFERENCES bell.events(id), swept_at timestamptz NOT NULL DEFAULT clock_timestamp() ); ALTER TABLE bell.event_rule_sweeps OWNER TO bell_app; CREATE TABLE IF NOT EXISTS bell.rule_evaluations ( id text PRIMARY KEY, event_id text NOT NULL REFERENCES bell.events(id), rule_version_id text NOT NULL REFERENCES bell.rule_versions(id), result text NOT NULL, reason text NOT NULL, evaluated_at timestamptz NOT NULL DEFAULT clock_timestamp(), CONSTRAINT bell_rule_evaluation_id CHECK (id ~ '^eva_[0-9A-HJKMNP-TV-Z]{26}$'), CONSTRAINT bell_rule_evaluation_result CHECK (result IN ('matched','no_match')), CONSTRAINT bell_rule_evaluation_reason CHECK (reason IN ('matched','disabled','event_kind','severity')), UNIQUE (event_id, rule_version_id) ); ALTER TABLE bell.rule_evaluations OWNER TO bell_app; CREATE TABLE IF NOT EXISTS bell.alerts ( id text PRIMARY KEY, tenant_id bigint NOT NULL, site_id bigint NOT NULL, rule_evaluation_id text NOT NULL UNIQUE REFERENCES bell.rule_evaluations(id), rule_version_id text NOT NULL REFERENCES bell.rule_versions(id), severity text NOT NULL, title text NOT NULL, created_at timestamptz NOT NULL DEFAULT clock_timestamp(), CONSTRAINT bell_alert_id CHECK (id ~ '^alt_[0-9A-HJKMNP-TV-Z]{26}$'), CONSTRAINT bell_alert_scope CHECK (tenant_id >= 1 AND site_id >= 1), CONSTRAINT bell_alert_severity CHECK (severity IN ('low','medium','high','critical')), CONSTRAINT bell_alert_title CHECK (char_length(btrim(title)) BETWEEN 1 AND 120) ); ALTER TABLE bell.alerts OWNER TO bell_app; CREATE TABLE IF NOT EXISTS bell.alert_events ( alert_id text NOT NULL REFERENCES bell.alerts(id), event_id text NOT NULL REFERENCES bell.events(id), linked_at timestamptz NOT NULL DEFAULT clock_timestamp(), PRIMARY KEY (alert_id, event_id), UNIQUE (event_id, alert_id) ); ALTER TABLE bell.alert_events OWNER TO bell_app; CREATE TABLE IF NOT EXISTS bell.alert_transitions ( id text PRIMARY KEY, alert_id text NOT NULL REFERENCES bell.alerts(id), sequence integer NOT NULL, from_state text, to_state text NOT NULL, actor_ref text NOT NULL, note text, occurred_at timestamptz NOT NULL DEFAULT clock_timestamp(), CONSTRAINT bell_alert_transition_id CHECK (id ~ '^trn_[0-9A-HJKMNP-TV-Z]{26}$'), CONSTRAINT bell_alert_transition_sequence CHECK (sequence >= 1), CONSTRAINT bell_alert_transition_from CHECK (from_state IS NULL OR from_state IN ('open','acknowledged')), CONSTRAINT bell_alert_transition_to CHECK (to_state IN ('open','acknowledged','closed')), CONSTRAINT bell_alert_transition_edge CHECK ( (sequence = 1 AND from_state IS NULL AND to_state = 'open') OR (sequence > 1 AND from_state = 'open' AND to_state = 'acknowledged') OR (sequence > 1 AND from_state = 'acknowledged' AND to_state = 'closed') ), CONSTRAINT bell_alert_transition_actor CHECK ( char_length(actor_ref) BETWEEN 1 AND 80 AND actor_ref ~ '^[A-Za-z0-9][A-Za-z0-9._:@/-]*$' ), CONSTRAINT bell_alert_transition_note CHECK (note IS NULL OR char_length(note) <= 500), UNIQUE (alert_id, sequence) ); ALTER TABLE bell.alert_transitions OWNER TO bell_app; CREATE TABLE IF NOT EXISTS bell.alert_command_receipts ( tenant_id bigint NOT NULL, idempotency_key text NOT NULL, command_hash bytea NOT NULL, response_status integer NOT NULL, response_body jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT clock_timestamp(), PRIMARY KEY (tenant_id, idempotency_key), CONSTRAINT bell_alert_command_tenant CHECK (tenant_id >= 1), CONSTRAINT bell_alert_command_key CHECK ( char_length(idempotency_key) BETWEEN 8 AND 128 AND idempotency_key ~ '^[A-Za-z0-9][A-Za-z0-9._:-]*$' ), CONSTRAINT bell_alert_command_hash CHECK (octet_length(command_hash) = 32), CONSTRAINT bell_alert_command_status CHECK (response_status IN (200,409)), CONSTRAINT bell_alert_command_body CHECK (jsonb_typeof(response_body) = 'object') ); ALTER TABLE bell.alert_command_receipts OWNER TO bell_app; DO $immutable$ DECLARE table_name text; BEGIN FOREACH table_name IN ARRAY ARRAY[ 'rule_versions','event_rule_sweeps','rule_evaluations','alerts', 'alert_events','alert_transitions','alert_command_receipts' ] LOOP EXECUTE format('DROP TRIGGER IF EXISTS %I ON bell.%I', 'bell_' || table_name || '_immutable', table_name); EXECUTE format( 'CREATE TRIGGER %I BEFORE UPDATE OR DELETE ON bell.%I FOR EACH ROW EXECUTE FUNCTION bell.reject_immutable_change()', 'bell_' || table_name || '_immutable', table_name ); END LOOP; END $immutable$; CREATE INDEX IF NOT EXISTS bell_rule_versions_effective_idx ON bell.rule_versions(tenant_id, rule_key, effective_from DESC, version DESC); CREATE INDEX IF NOT EXISTS bell_alerts_scope_time_idx ON bell.alerts(tenant_id, site_id, created_at DESC, id DESC); CREATE INDEX IF NOT EXISTS bell_alert_events_event_idx ON bell.alert_events(event_id, alert_id); CREATE INDEX IF NOT EXISTS bell_alert_transitions_latest_idx ON bell.alert_transitions(alert_id, sequence DESC); INSERT INTO bell.schema_migrations(version) VALUES (6) ON CONFLICT (version) DO NOTHING;