feat(bell): add alert acknowledgement vertical slice
Harness governance / validate (pull_request) Has been cancelled

This commit is contained in:
QiuSW
2026-08-11 17:01:53 +08:00
parent 477afa6ba2
commit 8208118904
29 changed files with 1820 additions and 56 deletions
+145
View File
@@ -0,0 +1,145 @@
-- 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;
@@ -0,0 +1,16 @@
-- Bell runtime may only append and read rule/Alert history.
REVOKE ALL ON TABLE bell.rule_versions, bell.event_rule_sweeps,
bell.rule_evaluations, bell.alerts, bell.alert_events,
bell.alert_transitions, bell.alert_command_receipts FROM PUBLIC;
REVOKE ALL ON TABLE bell.rule_versions, bell.event_rule_sweeps,
bell.rule_evaluations, bell.alerts, bell.alert_events,
bell.alert_transitions, bell.alert_command_receipts FROM bell_runtime;
GRANT SELECT, INSERT ON TABLE bell.rule_versions, bell.event_rule_sweeps,
bell.rule_evaluations, bell.alerts, bell.alert_events,
bell.alert_transitions, bell.alert_command_receipts TO bell_runtime;
REVOKE ALL ON TABLE bell.rule_versions, bell.event_rule_sweeps,
bell.rule_evaluations, bell.alerts, bell.alert_events,
bell.alert_transitions, bell.alert_command_receipts FROM sense_app;
+2 -1
View File
@@ -1,6 +1,6 @@
# YoVision PostgreSQL 初始化
本目录实现 T-009~T-012、T-015~T-016、T-019 的 PostgreSQL `17.10` schema。SQL 必须按文件名前缀顺序执行:`001`~`004` 创建 NOLOGIN 权限角色、Bell/Sense 初始对象和配额权限;`005`~`007` 增量增加 Area 与本地审计;`008`~`009` 增加 Control API 状态;`010`~`011` 增加调和 fencing、MediaMTX Path 历史归属、孤儿报告/受控处置结果;`012`~`013` 增加 Bell 不可变事件、append-only outcome 和独立 `bell_runtime` 最小权限;`014`~`015` 增加审计 relay;`016`~`017` 增加 Brain 事件身份绑定、永久来源收据、短期 nonce 和最小权限。全部 SQL 可重放。对象 owner/迁移角色为 `bell_app`/`sense_app`;应用登录角色及密码由部署环境或密钥系统创建,Sense 登录加入 `sense_app`,Bell 运行登录只加入 `bell_runtime`,仓库不保存登录凭据。
本目录实现 T-009~T-012、T-015~T-016、T-019~T-020 的 PostgreSQL `17.10` schema。SQL 必须按文件名前缀顺序执行:`001`~`004` 创建 NOLOGIN 权限角色、Bell/Sense 初始对象和配额权限;`005`~`007` 增量增加 Area 与本地审计;`008`~`009` 增加 Control API 状态;`010`~`011` 增加调和 fencing、MediaMTX Path 历史归属、孤儿报告/受控处置结果;`012`~`013` 增加 Bell 不可变事件、append-only outcome 和独立 `bell_runtime` 最小权限;`014`~`015` 增加审计 relay;`016`~`017` 增加 Brain 事件身份绑定、永久来源收据、短期 nonce 和最小权限;`018`~`019` 增加不可变规则版本、durable evaluation、Alert/Event 关联、append-only 状态迁移、永久命令收据和最小权限。全部 SQL 可重放。对象 owner/迁移角色为 `bell_app`/`sense_app`;应用登录角色及密码由部署环境或密钥系统创建,Sense 登录加入 `sense_app`,Bell 运行登录只加入 `bell_runtime`,仓库不保存登录凭据。
生产/共享实例必须由管理员先备份并在 YoVision 专用数据库中执行。Sense 进程不会用高权限自动建库或建角色。示例只使用私有环境变量,不把实际 DSN 写入脚本或日志:
@@ -24,6 +24,7 @@ Get-ChildItem deploy/postgres/[0-9][0-9][0-9]_*.sql |
- `bell.audit_events` 与事件事实一样不可更新/删除;`bell.audit_relay_receipts` 只为 10 分钟 nonce 幂等窗口保留,Bell runtime 仅可在这张限定表中查询、插入和清理过期记录。
- `bell.events` 与 `bell.event_outcomes` 由 `bell_app` 拥有;`bell_runtime` 只获得 `SELECT/INSERT`,没有 owner、`UPDATE`、`DELETE` 或 `TRUNCATE` 权限,数据库 trigger 再拒绝 owner 路径的意外事实改写。
- `bell.event_ingress_bindings` 由管理员维护并引用当前 Bell Site/Area 和 Sense Device;Bell runtime 只读绑定及 Sense 设备的逻辑 ID/Area/modality 列,不可读取 endpoint、credential、profile token 或 MediaMTX path。`event_ingress_receipts` 永久只追加;只有 `event_ingress_nonces` 可按 10 分钟 TTL 清理。
- `bell.rule_versions`、sweep/evaluation、Alert/Event 关联、transition 和命令收据均由 `bell_app` 拥有;`bell_runtime` 只有 `SELECT/INSERT`,数据库 trigger 拒绝 UPDATE/DELETE。当前状态从最新 transition 推导,同一 evaluation 由唯一约束最多创建一个 Alert;命令收据永久保留以支持跨重启幂等。
- `sense.control_idempotency_receipts` 不保存原始 Idempotency-Key,只保存 scope/request SHA-256 和脱敏响应快照;`batch_operations`/items 只保存逻辑 ID、状态和稳定错误,不保存连接秘密。
- 调和与孤儿租约使用 PostgreSQL `clock_timestamp()` 和 fencing token;过期 worker 不能提交完成/失败或扫描报告。`media_path_ownership`、扫描和处置表不保存 endpoint、credential 或 source URI;数据库约束禁止为 `unowned` finding 写删除结果。
- PUBLIC 对两个业务 schema 的表和函数没有权限。
+22 -1
View File
@@ -71,7 +71,7 @@ BEGIN
OR NOT has_table_privilege('sense_app', 'sense.device_operation_outbox', 'DELETE') THEN
RAISE EXCEPTION 'sense_app lacks access to its local audit Outbox';
END IF;
IF (SELECT max(version) FROM bell.schema_migrations) <> 5
IF (SELECT max(version) FROM bell.schema_migrations) <> 6
OR (SELECT max(version) FROM sense.schema_migrations) <> 6 THEN
RAISE EXCEPTION 'schema migration version drift';
END IF;
@@ -254,3 +254,24 @@ BEGIN
END IF;
END
$event_ingress$;
DO $bell_alerts$
DECLARE
target text;
BEGIN
FOREACH target IN ARRAY ARRAY[
'bell.rule_versions', 'bell.event_rule_sweeps', 'bell.rule_evaluations',
'bell.alerts', 'bell.alert_events', 'bell.alert_transitions',
'bell.alert_command_receipts'
] LOOP
IF NOT has_table_privilege('yovision_t015_bell', target, 'SELECT,INSERT')
OR has_table_privilege('yovision_t015_bell', target, 'UPDATE,DELETE,TRUNCATE') THEN
RAISE EXCEPTION 'Bell runtime violates append-only Alert privilege on %', target;
END IF;
IF has_table_privilege('public', target, 'SELECT,INSERT,UPDATE,DELETE,TRUNCATE')
OR has_table_privilege('sense_app', target, 'SELECT,INSERT,UPDATE,DELETE,TRUNCATE') THEN
RAISE EXCEPTION 'Bell Alert history leaked on %', target;
END IF;
END LOOP;
END
$bell_alerts$;