From 236964d2423cb6223db03e16a18cb4a6ad82e8c0 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Mon, 10 Aug 2026 23:53:11 +0800 Subject: [PATCH] feat(bell): add immutable event store [T-015] --- AGENTS.md | 5 +- Bell/.gitkeep | 1 - Bell/README.md | 22 ++ Bell/contracts/embed.go | 9 + Bell/contracts/event-v0.1.schema.json | 283 ++++++++++++++++ Bell/go.mod | 19 ++ Bell/go.sum | 33 ++ Bell/internal/event/event.go | 302 ++++++++++++++++++ Bell/internal/event/event_test.go | 184 +++++++++++ Bell/internal/store/postgres.go | 149 +++++++++ Bell/internal/store/postgres_test.go | 120 +++++++ deploy/postgres/012_bell_events.sql | 115 +++++++ .../postgres/013_privileges_bell_events.sql | 15 + deploy/postgres/README.md | 3 +- deploy/postgres/tests/assertions.sql | 24 +- docs/00-ai-start-here.md | 5 +- docs/03-tech-stack.md | 22 +- docs/04-architecture.md | 7 +- docs/06-tasks.md | 1 + docs/api.md | 2 + docs/current-state.md | 21 +- docs/raw/contracts/README.md | 2 +- docs/tasks/T-015.md | 15 +- init.ps1 | 6 +- init.sh | 6 +- scripts/test_postgres.ps1 | 16 +- tests/test_bell_event_contract.py | 63 ++++ tests/test_postgres_contract.py | 11 + 28 files changed, 1434 insertions(+), 27 deletions(-) delete mode 100644 Bell/.gitkeep create mode 100644 Bell/README.md create mode 100644 Bell/contracts/embed.go create mode 100644 Bell/contracts/event-v0.1.schema.json create mode 100644 Bell/go.mod create mode 100644 Bell/go.sum create mode 100644 Bell/internal/event/event.go create mode 100644 Bell/internal/event/event_test.go create mode 100644 Bell/internal/store/postgres.go create mode 100644 Bell/internal/store/postgres_test.go create mode 100644 deploy/postgres/012_bell_events.sql create mode 100644 deploy/postgres/013_privileges_bell_events.sql create mode 100644 tests/test_bell_event_contract.py diff --git a/AGENTS.md b/AGENTS.md index ff3de5a..ca75d3d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ YoVision 是智能视频事件平台:统一接入 ONVIF/RTSP 摄像头与后续异构传感器,完成检测、规则判定、事件留证和分级预警。 -当前为 **M0:摄像头兼容性验证与需求定稿**。默认交付 16 路,单站点按 32/64/128 路横向扩展;16 只能是默认配额,不能成为代码、数据库、数组、分页或批量操作的硬上限。 +当前已完成 M0、M1 和 M2 的本地基线,进入 **M3:Bell/Brain 最小事件闭环**。默认交付 16 路,单站点按 32/64/128 路横向扩展;16 只能是默认配额,不能成为代码、数据库、数组、分页或批量操作的硬上限。 ## 固定阅读顺序 @@ -83,6 +83,9 @@ Gitea 不可用时,只能继续已经确认属于自己的任务;不得领 python scripts/validate_agent_context.py python -m unittest discover -s tests -p "test_*.py" python scripts/validate_harness_governance.py +go -C Bell test ./... +go -C Bell vet ./... +go -C Bell build ./... ``` 代码出现后,还必须执行 `docs/03-tech-stack.md` 中与本任务命中的模块测试;完整门禁、设备验收和容量验收按任务文件触发。提交前检查 `git status --short`、`git diff`、`git diff --cached` 与 `git diff --check`。 diff --git a/Bell/.gitkeep b/Bell/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/Bell/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/Bell/README.md b/Bell/README.md new file mode 100644 index 0000000..df7df08 --- /dev/null +++ b/Bell/README.md @@ -0,0 +1,22 @@ +# Bell 事件存储基础 + +Bell 当前只实现 M3 的事件域基础,不包含可部署 HTTP 服务: + +- Bell 在可信 ingress 内为不含 `id` 的候选事实生成 `evt_` ULID。 +- 最终事件同时通过冻结 v0.1 JSON Schema 与六项代码级断言。 +- PostgreSQL `bell.events` 保存不可变事实;后续 outcome 追加到 `bell.event_outcomes`。 +- `bell_runtime` 只拥有两张表的 `SELECT/INSERT`,没有 `UPDATE/DELETE/TRUNCATE` 或 migration owner 权限。 + +Brain→Bell transport、认证、公共事件 API、规则、Alert 和证据对象存储仍需后续任务冻结,不能把 `internal/event` 的 Go 类型当成公共网络协议。 + +## 验证 + +```powershell +go -C Bell mod download +go -C Bell test ./... +go -C Bell vet ./... +go -C Bell build ./... +./scripts/test_postgres.ps1 -PgRoot D:\pgsql17 +``` + +隔离 PostgreSQL harness 会创建临时 `bell_runtime` 登录成员,运行真实 repository、幂等冲突和不可变权限测试,然后停止并删除临时集群;不会读取或修改现有 `D:\pgsql17\data` 或 5432 服务。 diff --git a/Bell/contracts/embed.go b/Bell/contracts/embed.go new file mode 100644 index 0000000..2638961 --- /dev/null +++ b/Bell/contracts/embed.go @@ -0,0 +1,9 @@ +// Package contracts embeds the frozen event contract used by Bell. +package contracts + +import _ "embed" + +// EventV01Schema is byte-identical to docs/raw/contracts/event-v0.1.schema.json. +// +//go:embed event-v0.1.schema.json +var EventV01Schema []byte diff --git a/Bell/contracts/event-v0.1.schema.json b/Bell/contracts/event-v0.1.schema.json new file mode 100644 index 0000000..ed77d14 --- /dev/null +++ b/Bell/contracts/event-v0.1.schema.json @@ -0,0 +1,283 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://yovision.local/contracts/event-v0.1.schema.json", + "title": "YoVision Event Instance v0.1", + "description": "推理侧 → 平台侧的唯一契约。冻结于 2026-08-03。所有顶层键必须存在(可为 null),不允许省略——省略与显式 null 无法区分,是这类系统最常见的排查陷阱。", + "type": "object", + "additionalProperties": false, + + "required": [ + "schema_version", + "id", + "source_event_id", + "tenant_id", + "site_id", + "device_id", + "sensors", + "kind", + "severity", + "confidence", + "occurred_at", + "detected_at", + "latency_seconds", + "config_version", + "rule", + "subject", + "observation", + "evidence", + "dedup_key", + "aggregated_into", + "outcome", + "outcome_source", + "outcome_reason", + "diagnostics", + "ext" + ], + + "properties": { + "schema_version": { + "description": "契约版本。破坏性变更必须递增主版本。", + "const": "0.1" + }, + + "id": { + "description": "平台侧生成的全局唯一事件 ID(ULID)。推理侧不得自行生成。", + "type": "string", + "pattern": "^evt_[0-9A-HJKMNP-TV-Z]{26}$" + }, + + "source_event_id": { + "description": "推理侧原始事件 ID,如 silver_pose 的 FALL--000001。用于回溯本地截图文件名(截图即按它命名)。会话内唯一,全局不保证唯一——不得用作主键。", + "type": "string", + "pattern": "^[A-Za-z0-9_-]{1,128}$" + }, + + "tenant_id": { "type": "integer", "minimum": 1 }, + "site_id": { "type": "integer", "minimum": 1 }, + "device_id": { + "description": "主传感器的平台设备实体主键。由推理侧的 source_id 经平台映射表解析得到。事件中不得冗余 RTSP 地址或任何凭据。多传感器融合事件的完整来源见 sensors。", + "type": "integer", + "minimum": 1 + }, + + "sensors": { + "description": "参与本次判定的全部传感器。单摄像头事件为单元素数组。恰好一个元素的 role 为 primary,且其 device_id 必须等于顶层 device_id。", + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["device_id", "modality", "role"], + "properties": { + "device_id": { "type": "integer", "minimum": 1 }, + "modality": { + "description": "设备模态。决定隐私区域准入:privacy_flag 为真的区域只允许非成像模态。", + "type": "string", + "enum": ["video", "radar", "contact", "button", "wearable", "other"] + }, + "role": { + "description": "primary=判定主依据;corroborating=佐证(如雷达判跌倒、门磁佐证无人离开)。", + "type": "string", + "enum": ["primary", "corroborating"] + } + } + } + }, + + "kind": { + "description": "事件类型。取值登记在 contracts/README.md 的类型注册表中,新增类型不需要升 schema 版本。v0.1 已登记:fall。", + "type": "string", + "pattern": "^[a-z][a-z0-9_]{2,63}$" + }, + + "severity": { + "type": "string", + "enum": ["low", "medium", "high", "critical"] + }, + + "confidence": { + "description": "模型置信度。几何+状态机判定链路没有天然来源,必须填 null——不得用任意常量或阈值余量伪造。", + "type": ["number", "null"], + "minimum": 0, + "maximum": 1 + }, + + "occurred_at": { + "description": "事发时刻(墙钟 UTC)。决定证据回捞窗口。推理侧若只有单调时钟,按 detected_at - latency_seconds 换算。", + "type": "string", + "format": "date-time" + }, + + "detected_at": { + "description": "判定成立时刻(墙钟 UTC)。决定 SLA 计算。必须 >= occurred_at。", + "type": "string", + "format": "date-time" + }, + + "latency_seconds": { + "description": "从可疑到确认的耗时。可由两时间戳相减,但显式存储:它是判定质量的直接指标——贴近确认窗口下限说明证据干脆,贴近上限是误报高发区,为误报排查的首选排序键。", + "type": "number", + "minimum": 0 + }, + + "config_version": { + "description": "产出本事件时整套判定配置的版本。粒度高于 rule.version(阈值往往是全局的),用于调参后的回归对比。不得为空串。", + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + + "rule": { + "description": "命中的规则实体。推理侧无规则引擎时为 null,由平台侧按 kind 反查补全。", + "type": ["object", "null"], + "additionalProperties": false, + "required": ["id", "version", "code"], + "properties": { + "id": { "type": "string" }, + "version": { "type": "integer", "minimum": 1 }, + "code": { "type": "string" } + } + }, + + "subject": { + "type": "object", + "additionalProperties": false, + "required": ["class", "track_id", "attributes", "anon_id", "identity", "identity_status"], + "properties": { + "class": { "type": "string", "enum": ["person", "vehicle", "object"] }, + "track_id": { + "description": "跟踪器内的短期标识,跨会话不保证稳定。", + "type": "string", + "minLength": 1 + }, + "attributes": { + "description": "A 类属性(年龄段、着装等)。未启用时为空对象,不是 null。", + "type": "object" + }, + "anon_id": { + "description": "B+ 类 ReID 匿名标识,站点内会话级有效(≤30min),不做跨日长期关联。未启用为 null。", + "type": ["string", "null"] + }, + "identity": { + "description": "C 类人脸身份。仅在租户已授权且比对命中时非 null。", + "type": ["object", "null"], + "additionalProperties": false, + "required": ["person_id", "library_id", "score"], + "properties": { + "person_id": { "type": "string" }, + "library_id": { "type": "string" }, + "score": { "type": "number", "minimum": 0, "maximum": 1 } + } + }, + "identity_status": { + "description": "必须显式。只写 null 无法区分「没开这功能」与「比对失败」,后者是需要排查的故障。", + "type": "string", + "enum": ["not_enabled", "pending", "matched", "below_threshold", "no_candidate", "timeout"] + } + } + }, + + "observation": { + "description": "结构化观测。bbox/keypoint 序列是数据闭环的唯一原料——只有视频与截图无法用于训练。", + "type": ["object", "null"], + "additionalProperties": false, + "required": ["zone", "dwell_sec", "bbox_seq_uri", "keypoint_seq_uri", "signal_seq_uri"], + "properties": { + "zone": { "type": ["string", "null"] }, + "dwell_sec": { "type": ["number", "null"], "minimum": 0 }, + "bbox_seq_uri": { + "description": "视觉模态专用。非视觉事件为 null。", + "type": ["string", "null"], + "format": "uri" + }, + "keypoint_seq_uri": { + "description": "COCO-17 关键点逐帧序列(JSONL)。视觉模态专用,P1 必补项。", + "type": ["string", "null"], + "format": "uri" + }, + "signal_seq_uri": { + "description": "非视觉模态的结构化序列(雷达点云轨迹与多普勒、门磁状态变迁等,JSONL)。与 keypoint_seq_uri 平级——两者是各自模态的数据闭环原料,缺任一模态的序列,该模态就无法参与模型迭代。", + "type": ["string", "null"], + "format": "uri" + } + } + }, + + "evidence": { + "type": "object", + "additionalProperties": false, + "required": ["snapshot_uris", "clip_uri", "clip_range"], + "properties": { + "snapshot_uris": { + "description": "证据截图。**允许为空数组**:非成像模态(雷达、门磁)产出的事件本就没有画面,隐私区域更是禁止成像。不得据此假设每个事件都有图可看——值班台 UI 必须能渲染无画面事件。文件命名只允许包含事件 ID 与日期目录,绝不得含 RTSP 地址、凭据或客户名称,文件名会出现在日志、URL 与工单中。", + "type": "array", + "minItems": 0, + "items": { "type": "string", "format": "uri" } + }, + "clip_uri": { + "description": "含 pre-roll 的证据片段。仅有截图不足以让值班员判断真假,是误报反馈闭环的前置条件。P1 必补项。", + "type": ["string", "null"], + "format": "uri" + }, + "clip_range": { + "type": ["array", "null"], + "minItems": 2, + "maxItems": 2, + "items": { "type": "string", "format": "date-time" } + } + } + }, + + "dedup_key": { + "description": "跨机位/跨时间去重键,由平台侧构造。推理侧进程内按 source_event_id 的去重仍保留——它防的是同帧重复写盘,属不同层次。", + "type": ["string", "null"] + }, + + "aggregated_into": { + "description": "被合并入的事件 ID。非 null 时本事件不独立触发告警。", + "type": ["string", "null"], + "pattern": "^evt_[0-9A-HJKMNP-TV-Z]{26}$" + }, + + "outcome": { + "description": "处置结果。事件不可变,误判只能通过本字段标记,不得删改。subject_recovered 由推理侧状态机自动回传(确认后自行起身),无需等人工。", + "type": "string", + "enum": [ + "unknown", + "true_positive", + "false_positive", + "subject_recovered", + "duplicate", + "test" + ] + }, + + "outcome_source": { + "type": ["string", "null"], + "enum": ["auto", "manual", null] + }, + + "outcome_reason": { "type": ["string", "null"] }, + + "diagnostics": { + "description": "推理侧内部诊断量,仅用于排查,平台不得依赖其语义。单调时钟跨进程无意义,不得用于任何时间计算。", + "type": ["object", "null"], + "additionalProperties": true, + "properties": { + "fsm_state": { + "type": "string", + "enum": ["NORMAL", "SUSPECT", "CONFIRMED", "RECOVERING"] + }, + "suspected_at_monotonic": { "type": "number" }, + "confirmed_at_monotonic": { "type": "number" }, + "horizontal_angle_degrees": { "type": ["number", "null"] }, + "visible_joint_count": { "type": ["integer", "null"], "minimum": 0, "maximum": 17 } + } + }, + + "ext": { + "description": "厂商/场景扩展位。根对象 additionalProperties=false,任何未登记字段一律放这里,避免为实验性字段升版本。", + "type": "object" + } + } +} diff --git a/Bell/go.mod b/Bell/go.mod new file mode 100644 index 0000000..7dcd61e --- /dev/null +++ b/Bell/go.mod @@ -0,0 +1,19 @@ +module yovision/bell + +go 1.26.0 + +toolchain go1.26.5 + +require ( + github.com/jackc/pgx/v5 v5.10.0 + github.com/oklog/ulid/v2 v2.1.2 + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 +) + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/text v0.29.0 // indirect +) diff --git a/Bell/go.sum b/Bell/go.sum new file mode 100644 index 0000000..145b549 --- /dev/null +++ b/Bell/go.sum @@ -0,0 +1,33 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/oklog/ulid/v2 v2.1.2 h1:IEclFb9JNvzYA6MW2SCxbLzcHTVsfqm3PrqGQJH5zec= +github.com/oklog/ulid/v2 v2.1.2/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= +github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/Bell/internal/event/event.go b/Bell/internal/event/event.go new file mode 100644 index 0000000..b31ac0d --- /dev/null +++ b/Bell/internal/event/event.go @@ -0,0 +1,302 @@ +// Package event assembles and validates immutable Bell event facts. +package event + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "net" + "net/url" + "regexp" + "strings" + "time" + + "github.com/oklog/ulid/v2" + jsonschema "github.com/santhosh-tekuri/jsonschema/v6" +) + +const MaxPayloadBytes = 1 << 20 + +type ErrorCode string + +const ( + CodeInvalidJSON ErrorCode = "invalid_json" + CodePayloadTooLarge ErrorCode = "payload_too_large" + CodeUpstreamID ErrorCode = "upstream_id_forbidden" + CodeSchema ErrorCode = "schema_invalid" + CodeTimeOrder ErrorCode = "time_order_invalid" + CodeLatency ErrorCode = "latency_inconsistent" + CodeConfidence ErrorCode = "confidence_forbidden" + CodeEvidence ErrorCode = "evidence_unsafe" + CodePrimarySensor ErrorCode = "primary_sensor_invalid" + CodePrivacyDenied ErrorCode = "privacy_denied" + CodePrivacyUnavailable ErrorCode = "privacy_unavailable" +) + +// ValidationError exposes a stable code without returning sensitive payloads. +type ValidationError struct { + Code ErrorCode + Err error +} + +func (e *ValidationError) Error() string { return string(e.Code) } +func (e *ValidationError) Unwrap() error { return e.Err } + +func validationError(code ErrorCode, err error) error { + return &ValidationError{Code: code, Err: err} +} + +// IDGenerator is owned by Bell. Upstream candidates are not allowed to carry id. +type IDGenerator interface { + NewEventID() (string, error) +} + +type ULIDGenerator struct{} + +func (ULIDGenerator) NewEventID() (string, error) { + return "evt_" + ulid.Make().String(), nil +} + +// PrivacyPolicy resolves the authoritative device/Area policy. Implementations +// must fail closed when the mapping is missing or stale. +type PrivacyPolicy interface { + VideoAllowed(ctx context.Context, tenantID, siteID, deviceID int64) (bool, error) +} + +// EvidencePolicy checks every evidence/observation URI before persistence. +type EvidencePolicy interface { + ValidateURI(rawURI string) error +} + +// EvidenceGuard rejects reusable credentials, network endpoints and configured +// customer/tenant names from persisted evidence URIs. +type EvidenceGuard struct { + forbidden []string +} + +func NewEvidenceGuard(forbiddenNames ...string) (*EvidenceGuard, error) { + guard := &EvidenceGuard{} + for _, name := range forbiddenNames { + name = strings.ToLower(strings.TrimSpace(name)) + if name == "" { + return nil, errors.New("forbidden evidence name cannot be blank") + } + guard.forbidden = append(guard.forbidden, name) + } + return guard, nil +} + +var ipv4Like = regexp.MustCompile(`(?:^|[^0-9])(?:[0-9]{1,3}\.){3}[0-9]{1,3}(?:[^0-9]|$)`) + +func (g *EvidenceGuard) ValidateURI(rawURI string) error { + parsed, err := url.Parse(rawURI) + if err != nil || parsed.Scheme == "" { + return errors.New("evidence URI is not absolute") + } + if parsed.User != nil || parsed.Port() != "" || parsed.RawQuery != "" || parsed.Fragment != "" { + return errors.New("evidence URI contains reusable connection material") + } + if host := parsed.Hostname(); host != "" && net.ParseIP(host) != nil { + return errors.New("evidence URI contains an IP address") + } + lower := strings.ToLower(rawURI) + for _, marker := range []string{"password", "passwd", "credential", "secret", "token=", "rtsp://"} { + if strings.Contains(lower, marker) { + return errors.New("evidence URI contains a forbidden marker") + } + } + if ipv4Like.MatchString(lower) { + return errors.New("evidence URI contains an IPv4-like value") + } + for _, name := range g.forbidden { + if strings.Contains(lower, name) { + return errors.New("evidence URI contains a configured sensitive name") + } + } + return nil +} + +type Sensor struct { + DeviceID int64 `json:"device_id"` + Modality string `json:"modality"` + Role string `json:"role"` +} + +type storedShape struct { + ID string `json:"id"` + TenantID int64 `json:"tenant_id"` + SiteID int64 `json:"site_id"` + DeviceID int64 `json:"device_id"` + SourceEventID string `json:"source_event_id"` + Sensors []Sensor `json:"sensors"` + Kind string `json:"kind"` + Severity string `json:"severity"` + Confidence *float64 `json:"confidence"` + OccurredAt time.Time `json:"occurred_at"` + DetectedAt time.Time `json:"detected_at"` + LatencySeconds float64 `json:"latency_seconds"` + Observation *struct { + BBoxSeqURI *string `json:"bbox_seq_uri"` + KeypointSeqURI *string `json:"keypoint_seq_uri"` + SignalSeqURI *string `json:"signal_seq_uri"` + } `json:"observation"` + Evidence struct { + SnapshotURIs []string `json:"snapshot_uris"` + ClipURI *string `json:"clip_uri"` + } `json:"evidence"` +} + +// Event is a final, schema-valid immutable fact. JSON returns a defensive copy. +type Event struct { + shape storedShape + payload []byte + digest [sha256.Size]byte +} + +func (e Event) ID() string { return e.shape.ID } +func (e Event) TenantID() int64 { return e.shape.TenantID } +func (e Event) SiteID() int64 { return e.shape.SiteID } +func (e Event) DeviceID() int64 { return e.shape.DeviceID } +func (e Event) SourceEventID() string { return e.shape.SourceEventID } +func (e Event) Kind() string { return e.shape.Kind } +func (e Event) Severity() string { return e.shape.Severity } +func (e Event) OccurredAt() time.Time { return e.shape.OccurredAt } +func (e Event) DetectedAt() time.Time { return e.shape.DetectedAt } +func (e Event) Digest() [sha256.Size]byte { return e.digest } +func (e Event) JSON() []byte { return bytes.Clone(e.payload) } + +type Factory struct { + schema *jsonschema.Schema + ids IDGenerator + privacy PrivacyPolicy + evidence EvidencePolicy +} + +func NewFactory(schemaJSON []byte, ids IDGenerator, privacy PrivacyPolicy, evidence EvidencePolicy) (*Factory, error) { + if ids == nil || privacy == nil || evidence == nil { + return nil, errors.New("event factory dependencies are required") + } + schemaDoc, err := jsonschema.UnmarshalJSON(bytes.NewReader(schemaJSON)) + if err != nil { + return nil, fmt.Errorf("parse event schema: %w", err) + } + compiler := jsonschema.NewCompiler() + compiler.AssertFormat() + if err := compiler.AddResource("event-v0.1.schema.json", schemaDoc); err != nil { + return nil, fmt.Errorf("register event schema: %w", err) + } + compiled, err := compiler.Compile("event-v0.1.schema.json") + if err != nil { + return nil, fmt.Errorf("compile event schema: %w", err) + } + return &Factory{schema: compiled, ids: ids, privacy: privacy, evidence: evidence}, nil +} + +// Create turns a producer candidate into the final stored v0.1 event. The +// candidate must contain every v0.1 field except the Bell-owned id. +func (f *Factory) Create(ctx context.Context, candidate []byte) (Event, error) { + if len(candidate) > MaxPayloadBytes { + return Event{}, validationError(CodePayloadTooLarge, nil) + } + decoder := json.NewDecoder(bytes.NewReader(candidate)) + decoder.UseNumber() + var object map[string]any + if err := decoder.Decode(&object); err != nil || object == nil { + return Event{}, validationError(CodeInvalidJSON, err) + } + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + return Event{}, validationError(CodeInvalidJSON, errors.New("multiple JSON values")) + } + if _, exists := object["id"]; exists { + return Event{}, validationError(CodeUpstreamID, nil) + } + id, err := f.ids.NewEventID() + if err != nil { + return Event{}, fmt.Errorf("generate Bell event id: %w", err) + } + object["id"] = id + payload, err := json.Marshal(object) + if err != nil { + return Event{}, validationError(CodeInvalidJSON, err) + } + if len(payload) > MaxPayloadBytes { + return Event{}, validationError(CodePayloadTooLarge, nil) + } + instance, err := jsonschema.UnmarshalJSON(bytes.NewReader(payload)) + if err != nil { + return Event{}, validationError(CodeInvalidJSON, err) + } + if err := f.schema.Validate(instance); err != nil { + return Event{}, validationError(CodeSchema, nil) + } + var shape storedShape + if err := json.Unmarshal(payload, &shape); err != nil { + return Event{}, validationError(CodeInvalidJSON, err) + } + if err := f.assertSemantics(ctx, shape); err != nil { + return Event{}, err + } + return Event{shape: shape, payload: payload, digest: sha256.Sum256(payload)}, nil +} + +func (f *Factory) assertSemantics(ctx context.Context, shape storedShape) error { + if shape.DetectedAt.Before(shape.OccurredAt) { + return validationError(CodeTimeOrder, nil) + } + actual := shape.DetectedAt.Sub(shape.OccurredAt).Seconds() + if math.Abs(actual-shape.LatencySeconds) >= 0.1 { + return validationError(CodeLatency, nil) + } + if shape.Confidence != nil { + return validationError(CodeConfidence, nil) + } + primary := 0 + for _, sensor := range shape.Sensors { + if sensor.Role == "primary" { + primary++ + if sensor.DeviceID != shape.DeviceID { + return validationError(CodePrimarySensor, nil) + } + } + if sensor.Modality == "video" { + allowed, err := f.privacy.VideoAllowed(ctx, shape.TenantID, shape.SiteID, sensor.DeviceID) + if err != nil { + return validationError(CodePrivacyUnavailable, nil) + } + if !allowed { + return validationError(CodePrivacyDenied, nil) + } + } + } + if primary != 1 { + return validationError(CodePrimarySensor, nil) + } + var uris []string + uris = append(uris, shape.Evidence.SnapshotURIs...) + if shape.Evidence.ClipURI != nil { + uris = append(uris, *shape.Evidence.ClipURI) + } + if shape.Observation != nil { + for _, value := range []*string{ + shape.Observation.BBoxSeqURI, + shape.Observation.KeypointSeqURI, + shape.Observation.SignalSeqURI, + } { + if value != nil { + uris = append(uris, *value) + } + } + } + for _, rawURI := range uris { + if err := f.evidence.ValidateURI(rawURI); err != nil { + return validationError(CodeEvidence, nil) + } + } + return nil +} diff --git a/Bell/internal/event/event_test.go b/Bell/internal/event/event_test.go new file mode 100644 index 0000000..8599f5d --- /dev/null +++ b/Bell/internal/event/event_test.go @@ -0,0 +1,184 @@ +package event_test + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" + + "yovision/bell/contracts" + "yovision/bell/internal/event" +) + +const fixedEventID = "evt_01J8XQ2K7M3P5R9T0V4W6Y8Z2B" + +type fixedIDs struct{ id string } + +func (f fixedIDs) NewEventID() (string, error) { return f.id, nil } + +type privacy struct { + allowed bool + err error + calls int +} + +func (p *privacy) VideoAllowed(context.Context, int64, int64, int64) (bool, error) { + p.calls++ + return p.allowed, p.err +} + +func contractPath(name string) string { + return filepath.Join("..", "..", "..", "docs", "raw", "contracts", name) +} + +func candidate(t *testing.T, name string) []byte { + t.Helper() + raw, err := os.ReadFile(contractPath(name)) + if err != nil { + t.Fatal(err) + } + var object map[string]any + if err := json.Unmarshal(raw, &object); err != nil { + t.Fatal(err) + } + delete(object, "id") + encoded, err := json.Marshal(object) + if err != nil { + t.Fatal(err) + } + return encoded +} + +func mutate(t *testing.T, raw []byte, fn func(map[string]any)) []byte { + t.Helper() + var object map[string]any + if err := json.Unmarshal(raw, &object); err != nil { + t.Fatal(err) + } + fn(object) + encoded, err := json.Marshal(object) + if err != nil { + t.Fatal(err) + } + return encoded +} + +func factory(t *testing.T, policy *privacy) *event.Factory { + t.Helper() + guard, err := event.NewEvidenceGuard("private-customer") + if err != nil { + t.Fatal(err) + } + value, err := event.NewFactory(contracts.EventV01Schema, fixedIDs{id: fixedEventID}, policy, guard) + if err != nil { + t.Fatal(err) + } + return value +} + +func assertCode(t *testing.T, err error, code event.ErrorCode) { + t.Helper() + var validation *event.ValidationError + if !errors.As(err, &validation) || validation.Code != code { + t.Fatalf("expected %s, got %v", code, err) + } +} + +func TestFrozenContractCopyIsExact(t *testing.T) { + raw, err := os.ReadFile(contractPath("event-v0.1.schema.json")) + if err != nil { + t.Fatal(err) + } + if string(raw) != string(contracts.EventV01Schema) { + t.Fatal("Bell contract copy drifted from the frozen source") + } +} + +func TestFactoryAcceptsAllFrozenExamples(t *testing.T) { + for _, name := range []string{ + "event-v0.1.example-current.json", + "event-v0.1.example-target.json", + "event-v0.1.example-radar.json", + } { + t.Run(name, func(t *testing.T) { + policy := &privacy{allowed: true} + created, err := factory(t, policy).Create(context.Background(), candidate(t, name)) + if err != nil { + t.Fatal(err) + } + if created.ID() != fixedEventID || len(created.JSON()) == 0 { + t.Fatal("Bell did not assemble the final event") + } + if name == "event-v0.1.example-radar.json" && policy.calls != 0 { + t.Fatal("non-video event unexpectedly consulted video policy") + } + }) + } +} + +func TestFactoryRejectsUpstreamIDAndUnknownField(t *testing.T) { + policy := &privacy{allowed: true} + base := candidate(t, "event-v0.1.example-current.json") + withID := mutate(t, base, func(object map[string]any) { object["id"] = fixedEventID }) + _, err := factory(t, policy).Create(context.Background(), withID) + assertCode(t, err, event.CodeUpstreamID) + + unknown := mutate(t, base, func(object map[string]any) { object["surprise"] = true }) + _, err = factory(t, policy).Create(context.Background(), unknown) + assertCode(t, err, event.CodeSchema) +} + +func TestFactoryEnforcesCrossFieldAssertions(t *testing.T) { + base := candidate(t, "event-v0.1.example-current.json") + tests := []struct { + name string + code event.ErrorCode + edit func(map[string]any) + }{ + {"time-order", event.CodeTimeOrder, func(v map[string]any) { v["occurred_at"] = "2026-08-03T10:31:23.000Z" }}, + {"latency", event.CodeLatency, func(v map[string]any) { v["latency_seconds"] = 9.0 }}, + {"confidence", event.CodeConfidence, func(v map[string]any) { v["confidence"] = 0.9 }}, + {"primary", event.CodePrimarySensor, func(v map[string]any) { + v["sensors"] = []any{ + map[string]any{"device_id": float64(5012), "modality": "video", "role": "primary"}, + map[string]any{"device_id": float64(5013), "modality": "radar", "role": "primary"}, + } + }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := factory(t, &privacy{allowed: true}).Create(context.Background(), mutate(t, base, test.edit)) + assertCode(t, err, test.code) + }) + } +} + +func TestFactoryFailsClosedForPrivacyAndEvidence(t *testing.T) { + base := candidate(t, "event-v0.1.example-current.json") + _, err := factory(t, &privacy{err: errors.New("mapping unavailable")}).Create(context.Background(), base) + assertCode(t, err, event.CodePrivacyUnavailable) + + _, err = factory(t, &privacy{allowed: false}).Create(context.Background(), base) + assertCode(t, err, event.CodePrivacyDenied) + + unsafe := mutate(t, base, func(v map[string]any) { + evidence := v["evidence"].(map[string]any) + evidence["snapshot_uris"] = []any{"rtsp://user:password@10.0.0.1:554/private-customer.png"} + }) + _, err = factory(t, &privacy{allowed: true}).Create(context.Background(), unsafe) + assertCode(t, err, event.CodeEvidence) +} + +func TestFactoryRequiresFailClosedPoliciesAndPayloadLimit(t *testing.T) { + guard, err := event.NewEvidenceGuard() + if err != nil { + t.Fatal(err) + } + if _, err := event.NewFactory(contracts.EventV01Schema, fixedIDs{id: fixedEventID}, nil, guard); err == nil { + t.Fatal("nil privacy policy unexpectedly accepted") + } + _, err = factory(t, &privacy{allowed: true}).Create(context.Background(), make([]byte, event.MaxPayloadBytes+1)) + assertCode(t, err, event.CodePayloadTooLarge) +} diff --git a/Bell/internal/store/postgres.go b/Bell/internal/store/postgres.go new file mode 100644 index 0000000..b46e225 --- /dev/null +++ b/Bell/internal/store/postgres.go @@ -0,0 +1,149 @@ +package store + +import ( + "bytes" + "context" + "crypto/sha256" + "database/sql" + "encoding/json" + "errors" + "fmt" + "regexp" + "time" + "unicode/utf8" + + "yovision/bell/internal/event" +) + +var ErrConflict = errors.New("immutable record id conflict") + +type Postgres struct { + db *sql.DB +} + +func OpenPostgres(ctx context.Context, db *sql.DB) (*Postgres, error) { + if db == nil { + return nil, errors.New("postgres database is required") + } + if err := db.PingContext(ctx); err != nil { + return nil, fmt.Errorf("ping Bell postgres: %w", err) + } + var version int64 + if err := db.QueryRowContext(ctx, `SELECT COALESCE(MAX(version), 0) FROM bell.schema_migrations`).Scan(&version); err != nil || version < 3 { + return nil, errors.New("postgres Bell schema migration v3 is required") + } + var canInsert, canSelect, canUpdate, canDelete, canTruncate bool + if err := db.QueryRowContext(ctx, `SELECT + has_table_privilege(current_user, 'bell.events', 'INSERT'), + has_table_privilege(current_user, 'bell.events', 'SELECT'), + has_table_privilege(current_user, 'bell.events', 'UPDATE'), + has_table_privilege(current_user, 'bell.events', 'DELETE'), + has_table_privilege(current_user, 'bell.events', 'TRUNCATE')`).Scan( + &canInsert, &canSelect, &canUpdate, &canDelete, &canTruncate, + ); err != nil { + return nil, fmt.Errorf("inspect Bell event privileges: %w", err) + } + if !canInsert || !canSelect || canUpdate || canDelete || canTruncate { + return nil, errors.New("Bell runtime event privileges violate append-only boundary") + } + return &Postgres{db: db}, nil +} + +// InsertEvent is idempotent only for the same platform ID and exact payload. +func (p *Postgres) InsertEvent(ctx context.Context, value event.Event) (bool, error) { + digest := value.Digest() + result, err := p.db.ExecContext(ctx, `INSERT INTO bell.events( + id, tenant_id, site_id, device_id, source_event_id, kind, severity, + occurred_at, detected_at, payload_hash, payload + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::jsonb) + ON CONFLICT (id) DO NOTHING`, + value.ID(), value.TenantID(), value.SiteID(), value.DeviceID(), + value.SourceEventID(), value.Kind(), value.Severity(), value.OccurredAt(), + value.DetectedAt(), digest[:], value.JSON(), + ) + if err != nil { + return false, fmt.Errorf("insert immutable Bell event: %w", err) + } + rows, err := result.RowsAffected() + if err != nil { + return false, fmt.Errorf("read Bell event insert result: %w", err) + } + if rows == 1 { + return true, nil + } + var existing []byte + if err := p.db.QueryRowContext(ctx, `SELECT payload_hash FROM bell.events WHERE id=$1`, value.ID()).Scan(&existing); err != nil { + return false, fmt.Errorf("read existing Bell event digest: %w", err) + } + if !bytes.Equal(existing, digest[:]) { + return false, ErrConflict + } + return false, nil +} + +type Outcome struct { + ID string `json:"id"` + EventID string `json:"event_id"` + Value string `json:"outcome"` + Source string `json:"source"` + Reason *string `json:"reason"` + ActorType string `json:"actor_type"` + ActorID string `json:"actor_id"` + OccurredAt time.Time `json:"occurred_at"` +} + +var outcomeID = regexp.MustCompile(`^out_[0-9A-HJKMNP-TV-Z]{26}$`) +var eventID = regexp.MustCompile(`^evt_[0-9A-HJKMNP-TV-Z]{26}$`) + +func (o Outcome) validate() error { + if !outcomeID.MatchString(o.ID) || !eventID.MatchString(o.EventID) || o.ActorID == "" || o.OccurredAt.IsZero() { + return errors.New("invalid outcome identity") + } + validOutcome := map[string]bool{"unknown": true, "true_positive": true, "false_positive": true, "subject_recovered": true, "duplicate": true, "test": true} + if !validOutcome[o.Value] || (o.Source != "auto" && o.Source != "manual") { + return errors.New("invalid outcome value or source") + } + if o.ActorType != "user" && o.ActorType != "service" && o.ActorType != "system" { + return errors.New("invalid outcome actor type") + } + if o.Reason != nil && utf8.RuneCountInString(*o.Reason) > 500 { + return errors.New("outcome reason is too long") + } + return nil +} + +// AppendOutcome never mutates the event or an earlier outcome record. +func (p *Postgres) AppendOutcome(ctx context.Context, value Outcome) (bool, error) { + if err := value.validate(); err != nil { + return false, err + } + encoded, err := json.Marshal(value) + if err != nil { + return false, fmt.Errorf("encode outcome: %w", err) + } + digest := sha256.Sum256(encoded) + result, err := p.db.ExecContext(ctx, `INSERT INTO bell.event_outcomes( + id, event_id, outcome, outcome_source, reason, actor_type, actor_id, + occurred_at, record_hash + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) + ON CONFLICT (id) DO NOTHING`, value.ID, value.EventID, value.Value, value.Source, + value.Reason, value.ActorType, value.ActorID, value.OccurredAt, digest[:]) + if err != nil { + return false, fmt.Errorf("append Bell event outcome: %w", err) + } + rows, err := result.RowsAffected() + if err != nil { + return false, fmt.Errorf("read Bell outcome insert result: %w", err) + } + if rows == 1 { + return true, nil + } + var existing []byte + if err := p.db.QueryRowContext(ctx, `SELECT record_hash FROM bell.event_outcomes WHERE id=$1`, value.ID).Scan(&existing); err != nil { + return false, fmt.Errorf("read existing Bell outcome digest: %w", err) + } + if !bytes.Equal(existing, digest[:]) { + return false, ErrConflict + } + return false, nil +} diff --git a/Bell/internal/store/postgres_test.go b/Bell/internal/store/postgres_test.go new file mode 100644 index 0000000..3e5aaf1 --- /dev/null +++ b/Bell/internal/store/postgres_test.go @@ -0,0 +1,120 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" + "time" + + _ "github.com/jackc/pgx/v5/stdlib" + + "yovision/bell/contracts" + "yovision/bell/internal/event" +) + +type storeIDs struct{ id string } + +func (f storeIDs) NewEventID() (string, error) { return f.id, nil } + +type allowVideo struct{} + +func (allowVideo) VideoAllowed(context.Context, int64, int64, int64) (bool, error) { + return true, nil +} + +func testCandidate(t *testing.T, configVersion string) []byte { + t.Helper() + path := filepath.Join("..", "..", "..", "docs", "raw", "contracts", "event-v0.1.example-current.json") + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var object map[string]any + if err := json.Unmarshal(raw, &object); err != nil { + t.Fatal(err) + } + delete(object, "id") + object["config_version"] = configVersion + encoded, err := json.Marshal(object) + if err != nil { + t.Fatal(err) + } + return encoded +} + +func newEvent(t *testing.T, configVersion string) event.Event { + t.Helper() + guard, err := event.NewEvidenceGuard() + if err != nil { + t.Fatal(err) + } + factory, err := event.NewFactory( + contracts.EventV01Schema, + storeIDs{id: "evt_01J8XQ2K7M3P5R9T0V4W6Y8Z2B"}, + allowVideo{}, guard, + ) + if err != nil { + t.Fatal(err) + } + value, err := factory.Create(context.Background(), testCandidate(t, configVersion)) + if err != nil { + t.Fatal(err) + } + return value +} + +func TestPostgresImmutableEventAndOutcome(t *testing.T) { + dsn := os.Getenv("YOVISION_TEST_BELL_POSTGRES_DSN") + if dsn == "" { + t.Skip("YOVISION_TEST_BELL_POSTGRES_DSN is not set") + } + db, err := sql.Open("pgx", dsn) + if err != nil { + t.Fatal(err) + } + defer db.Close() + ctx := context.Background() + repo, err := OpenPostgres(ctx, db) + if err != nil { + t.Fatal(err) + } + + value := newEvent(t, "sp-v1-2026.07.20") + created, err := repo.InsertEvent(ctx, value) + if err != nil || !created { + t.Fatalf("first insert: created=%v err=%v", created, err) + } + created, err = repo.InsertEvent(ctx, value) + if err != nil || created { + t.Fatalf("idempotent replay: created=%v err=%v", created, err) + } + if _, err := repo.InsertEvent(ctx, newEvent(t, "sp-v1-conflict")); !errors.Is(err, ErrConflict) { + t.Fatalf("expected immutable conflict, got %v", err) + } + + reason := "confirmed by operator" + outcome := Outcome{ + ID: "out_01J8XQ2K7M3P5R9T0V4W6Y8Z2B", EventID: value.ID(), + Value: "true_positive", Source: "manual", Reason: &reason, + ActorType: "user", ActorID: "operator-1", OccurredAt: time.Now().UTC(), + } + created, err = repo.AppendOutcome(ctx, outcome) + if err != nil || !created { + t.Fatalf("append outcome: created=%v err=%v", created, err) + } + created, err = repo.AppendOutcome(ctx, outcome) + if err != nil || created { + t.Fatalf("idempotent outcome replay: created=%v err=%v", created, err) + } + + if _, err := db.ExecContext(ctx, `UPDATE bell.events SET kind='changed' WHERE id=$1`, value.ID()); err == nil { + t.Fatal("runtime unexpectedly updated immutable event") + } + if _, err := db.ExecContext(ctx, `DELETE FROM bell.event_outcomes WHERE id=$1`, outcome.ID); err == nil { + t.Fatal("runtime unexpectedly deleted immutable outcome") + } +} diff --git a/deploy/postgres/012_bell_events.sql b/deploy/postgres/012_bell_events.sql new file mode 100644 index 0000000..bd4b397 --- /dev/null +++ b/deploy/postgres/012_bell_events.sql @@ -0,0 +1,115 @@ +-- Bell v3 immutable event facts and append-only outcomes. +-- bell_app remains the migration/object owner; bell_runtime is a NOLOGIN +-- least-privilege group for the Bell process. + +DO $role$ +DECLARE + role_record pg_roles%ROWTYPE; +BEGIN + SELECT * INTO role_record FROM pg_roles WHERE rolname = 'bell_runtime'; + IF NOT FOUND THEN + CREATE ROLE bell_runtime NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION; + ELSIF role_record.rolcanlogin OR role_record.rolsuper OR role_record.rolcreatedb + OR role_record.rolcreaterole OR role_record.rolreplication THEN + RAISE EXCEPTION 'existing bell_runtime role has unsafe attributes'; + END IF; + IF pg_has_role('bell_runtime', 'bell_app', 'MEMBER') + OR pg_has_role('bell_runtime', 'sense_app', 'MEMBER') THEN + RAISE EXCEPTION 'bell_runtime must not inherit migration or Sense roles'; + END IF; +END +$role$; + +CREATE OR REPLACE FUNCTION bell.reject_immutable_change() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, bell +AS $function$ +BEGIN + RAISE EXCEPTION 'Bell immutable facts cannot be updated or deleted'; +END +$function$; +ALTER FUNCTION bell.reject_immutable_change() OWNER TO bell_app; + +CREATE TABLE IF NOT EXISTS bell.events ( + id text PRIMARY KEY, + tenant_id bigint NOT NULL, + site_id bigint NOT NULL, + device_id bigint NOT NULL, + source_event_id text NOT NULL, + kind text NOT NULL, + severity text NOT NULL, + occurred_at timestamptz NOT NULL, + detected_at timestamptz NOT NULL, + payload_hash bytea NOT NULL, + payload jsonb NOT NULL, + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + CONSTRAINT bell_event_id_format CHECK (id ~ '^evt_[0-9A-HJKMNP-TV-Z]{26}$'), + CONSTRAINT bell_event_ids_positive CHECK (tenant_id >= 1 AND site_id >= 1 AND device_id >= 1), + CONSTRAINT bell_event_source_id CHECK (source_event_id ~ '^[A-Za-z0-9_-]{1,128}$'), + CONSTRAINT bell_event_kind CHECK (kind ~ '^[a-z][a-z0-9_]{2,63}$'), + CONSTRAINT bell_event_severity CHECK (severity IN ('low', 'medium', 'high', 'critical')), + CONSTRAINT bell_event_time_order CHECK (detected_at >= occurred_at), + CONSTRAINT bell_event_hash_length CHECK (octet_length(payload_hash) = 32), + CONSTRAINT bell_event_payload_object CHECK (jsonb_typeof(payload) = 'object'), + CONSTRAINT bell_event_payload_identity CHECK ( + payload ->> 'id' = id + AND (payload ->> 'tenant_id')::bigint = tenant_id + AND (payload ->> 'site_id')::bigint = site_id + AND (payload ->> 'device_id')::bigint = device_id + AND payload ->> 'source_event_id' = source_event_id + AND payload ->> 'kind' = kind + AND payload ->> 'severity' = severity + ) +); +ALTER TABLE bell.events OWNER TO bell_app; + +DROP TRIGGER IF EXISTS bell_events_immutable ON bell.events; +CREATE TRIGGER bell_events_immutable +BEFORE UPDATE OR DELETE ON bell.events +FOR EACH ROW EXECUTE FUNCTION bell.reject_immutable_change(); + +CREATE INDEX IF NOT EXISTS bell_events_scope_time_idx + ON bell.events(tenant_id, site_id, occurred_at DESC, id DESC); +CREATE INDEX IF NOT EXISTS bell_events_device_time_idx + ON bell.events(tenant_id, site_id, device_id, occurred_at DESC, id DESC); +CREATE INDEX IF NOT EXISTS bell_events_kind_time_idx + ON bell.events(tenant_id, site_id, kind, occurred_at DESC, id DESC); +CREATE INDEX IF NOT EXISTS bell_events_source_lookup_idx + ON bell.events(tenant_id, site_id, device_id, source_event_id); + +CREATE TABLE IF NOT EXISTS bell.event_outcomes ( + id text PRIMARY KEY, + event_id text NOT NULL REFERENCES bell.events(id), + outcome text NOT NULL, + outcome_source text NOT NULL, + reason text, + actor_type text NOT NULL, + actor_id text NOT NULL, + occurred_at timestamptz NOT NULL, + record_hash bytea NOT NULL, + received_at timestamptz NOT NULL DEFAULT clock_timestamp(), + CONSTRAINT bell_event_outcome_id_format CHECK (id ~ '^out_[0-9A-HJKMNP-TV-Z]{26}$'), + CONSTRAINT bell_event_outcome_value CHECK ( + outcome IN ('unknown', 'true_positive', 'false_positive', 'subject_recovered', 'duplicate', 'test') + ), + CONSTRAINT bell_event_outcome_source CHECK (outcome_source IN ('auto', 'manual')), + CONSTRAINT bell_event_outcome_actor CHECK ( + actor_type IN ('user', 'service', 'system') AND btrim(actor_id) <> '' + ), + CONSTRAINT bell_event_outcome_reason CHECK (reason IS NULL OR char_length(reason) <= 500), + CONSTRAINT bell_event_outcome_hash_length CHECK (octet_length(record_hash) = 32) +); +ALTER TABLE bell.event_outcomes OWNER TO bell_app; + +DROP TRIGGER IF EXISTS bell_event_outcomes_immutable ON bell.event_outcomes; +CREATE TRIGGER bell_event_outcomes_immutable +BEFORE UPDATE OR DELETE ON bell.event_outcomes +FOR EACH ROW EXECUTE FUNCTION bell.reject_immutable_change(); + +CREATE INDEX IF NOT EXISTS bell_event_outcomes_event_time_idx + ON bell.event_outcomes(event_id, occurred_at, id); + +INSERT INTO bell.schema_migrations(version) VALUES (3) +ON CONFLICT (version) DO NOTHING; diff --git a/deploy/postgres/013_privileges_bell_events.sql b/deploy/postgres/013_privileges_bell_events.sql new file mode 100644 index 0000000..678438f --- /dev/null +++ b/deploy/postgres/013_privileges_bell_events.sql @@ -0,0 +1,15 @@ +-- Runtime Bell can append/read event facts but cannot rewrite, delete or +-- truncate them. Object ownership stays with the offline migration role. + +REVOKE ALL ON FUNCTION bell.reject_immutable_change() FROM PUBLIC; +REVOKE ALL ON TABLE bell.events, bell.event_outcomes FROM PUBLIC; +REVOKE ALL ON TABLE bell.events, bell.event_outcomes FROM bell_runtime; + +GRANT USAGE ON SCHEMA bell TO bell_runtime; +GRANT SELECT ON TABLE bell.schema_migrations TO bell_runtime; +GRANT SELECT, INSERT ON TABLE bell.events, bell.event_outcomes TO bell_runtime; + +ALTER DEFAULT PRIVILEGES FOR ROLE bell_app IN SCHEMA bell + REVOKE ALL ON TABLES FROM PUBLIC; +ALTER DEFAULT PRIVILEGES FOR ROLE bell_app IN SCHEMA bell + REVOKE ALL ON FUNCTIONS FROM PUBLIC; diff --git a/deploy/postgres/README.md b/deploy/postgres/README.md index 76c89be..4967d80 100644 --- a/deploy/postgres/README.md +++ b/deploy/postgres/README.md @@ -1,6 +1,6 @@ # YoVision PostgreSQL 初始化 -本目录实现 T-009~T-012 的 PostgreSQL `17.10` schema。SQL 必须按文件名前缀顺序执行:`001`~`004` 创建 NOLOGIN 权限角色、Bell/Sense 初始对象和配额权限;`005`~`007` 增量增加 Area 与审计;`008`~`009` 增加 Control API 状态;`010`~`011` 增加调和 fencing、MediaMTX Path 历史归属、孤儿报告/受控处置结果和对应最小权限。全部 SQL 可重放。应用登录角色及其密码由部署环境或密钥系统创建,并分别加入 `bell_app`、`sense_app`;仓库不保存登录凭据。 +本目录实现 T-009~T-012、T-015 的 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` 最小权限。全部 SQL 可重放。对象 owner/迁移角色为 `bell_app`/`sense_app`;应用登录角色及密码由部署环境或密钥系统创建,Sense 登录加入 `sense_app`,Bell 运行登录只加入 `bell_runtime`,仓库不保存登录凭据。 生产/共享实例必须由管理员先备份并在 YoVision 专用数据库中执行。Sense 进程不会用高权限自动建库或建角色。示例只使用私有环境变量,不把实际 DSN 写入脚本或日志: @@ -21,6 +21,7 @@ Get-ChildItem deploy/postgres/[0-9][0-9][0-9]_*.sql | - `sense_app` 拥有 `sense` schema,只获得 `bell` schema 的 `USAGE` 和两个投影视图的 `SELECT`。 - `sense_app` 对 Bell 源表、Bell migration 表和 trigger function 没有权限;启动检查发现权限过宽时拒绝运行。 - `sense.device_operation_outbox` 是本地持久化审计事实,不是 Bell 全局审计真相;relay 的传输、签名、确认和留存尚未实现。 +- `bell.events` 与 `bell.event_outcomes` 由 `bell_app` 拥有;`bell_runtime` 只获得 `SELECT/INSERT`,没有 owner、`UPDATE`、`DELETE` 或 `TRUNCATE` 权限,数据库 trigger 再拒绝 owner 路径的意外事实改写。 - `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 的表和函数没有权限。 diff --git a/deploy/postgres/tests/assertions.sql b/deploy/postgres/tests/assertions.sql index 82fc204..1f6f1b8 100644 --- a/deploy/postgres/tests/assertions.sql +++ b/deploy/postgres/tests/assertions.sql @@ -71,11 +71,33 @@ 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) <> 2 + IF (SELECT max(version) FROM bell.schema_migrations) <> 3 OR (SELECT max(version) FROM sense.schema_migrations) <> 5 THEN RAISE EXCEPTION 'schema migration version drift'; END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_roles + WHERE rolname = 'bell_runtime' AND NOT rolcanlogin AND NOT rolsuper + AND NOT rolcreatedb AND NOT rolcreaterole AND NOT rolreplication + ) THEN + RAISE EXCEPTION 'bell_runtime role is missing or unsafe'; + END IF; + IF NOT has_table_privilege('yovision_t015_bell', 'bell.events', 'SELECT,INSERT') + OR NOT has_table_privilege('yovision_t015_bell', 'bell.event_outcomes', 'SELECT,INSERT') + OR has_table_privilege('yovision_t015_bell', 'bell.events', 'UPDATE') + OR has_table_privilege('yovision_t015_bell', 'bell.events', 'DELETE') + OR has_table_privilege('yovision_t015_bell', 'bell.events', 'TRUNCATE') + OR has_table_privilege('yovision_t015_bell', 'bell.event_outcomes', 'UPDATE') + OR has_table_privilege('yovision_t015_bell', 'bell.event_outcomes', 'DELETE') + OR has_table_privilege('yovision_t015_bell', 'bell.event_outcomes', 'TRUNCATE') THEN + RAISE EXCEPTION 'Bell runtime violates immutable event privileges'; + END IF; + IF has_table_privilege('public', 'bell.events', 'SELECT,INSERT,UPDATE,DELETE,TRUNCATE') + OR has_table_privilege('public', 'bell.event_outcomes', 'SELECT,INSERT,UPDATE,DELETE,TRUNCATE') THEN + RAISE EXCEPTION 'Bell event facts leaked to PUBLIC'; + END IF; + INSERT INTO bell.sites(tenant_id, id, name) VALUES ('assertion-tenant', 'assertion-site', 'Assertion Site') RETURNING max_video_channels, version INTO default_quota, initial_version; diff --git a/docs/00-ai-start-here.md b/docs/00-ai-start-here.md index da5a99f..e6d6be7 100644 --- a/docs/00-ai-start-here.md +++ b/docs/00-ai-start-here.md @@ -40,7 +40,7 @@ MVP 以默认 16 路跑通一个场景的端到端闭环;架构、数据和 UI ## 当前阶段 -当前为 **M0 指定型号实机准入、M1 Sense 五路混合源集成和 M2 本地 16 路软件基线均已完成,正在转入 M3 的 Bell/Brain 最小闭环**。后续本地开发统一使用已准入的一台海康样机,多路软件闭环使用独立合成 RTSP 源补足;真实多设备证据延后到客户/借用/租赁条件具备时执行。客户网络尚未提供,T-013 WireGuard 继续后置,不阻塞 Bell 事件存储与审计 relay 边界设计。 +当前为 **M0 指定型号实机准入、M1 Sense 五路混合源集成和 M2 本地 16 路软件基线均已完成,M3 已建立 Bell 不可变事件存储基础**。后续本地开发统一使用已准入的一台海康样机,多路软件闭环使用独立合成 RTSP 源补足;真实多设备证据延后到客户/借用/租赁条件具备时执行。客户网络尚未提供,T-013 WireGuard 继续后置,不阻塞 Sense Outbox → Bell 审计 relay。 优先路径: @@ -89,6 +89,9 @@ go -C Sense generate ./internal/mtx ./internal/controlapi go -C Sense test ./... go -C Sense vet ./... go -C Sense build ./... +go -C Bell test ./... +go -C Bell vet ./... +go -C Bell build ./... ``` 日常优先运行根目录 `./init.ps1` 或 `./init.sh`,它会执行上述治理、生成、测试、静态检查和构建门禁。Sense 本地启动为 `go -C Sense run ./cmd/sense-api`;默认只监听回环地址,具体配置、MediaMTX 版本与校验方法见 [`03-tech-stack.md`](03-tech-stack.md) 和 [`../Sense/README.md`](../Sense/README.md)。 diff --git a/docs/03-tech-stack.md b/docs/03-tech-stack.md index 22f6393..7f5803f 100644 --- a/docs/03-tech-stack.md +++ b/docs/03-tech-stack.md @@ -56,6 +56,17 @@ T-012 同样未增加第三方依赖:PostgreSQL v5 使用数据库时钟租约 T-014 没有增加生产依赖。Windows 容量脚本冻结并核对 Sense 模块 Go 1.26.5、PostgreSQL 17.10、MediaMTX v1.19.3 与 FFmpeg 8.1.2;MediaMTX Windows amd64 ZIP 使用上表官方 SHA-256,解压后 `mediamtx.exe` 另固定为 `1cda85249312cb9463f9f94c5a712b9f160c9af3fd9490f0d4723911d7880e05`。FFmpeg 只生成无人物夹具并以 16 个独立 `-c copy` 进程发布,不进入生产镜像或 Go module。正式本机结果与限制见 [`research/sense-16-stream-capacity.md`](research/sense-16-stream-capacity.md)。 +### 1.3 Bell M3 事件存储冻结版本(T-015) + +| 组件 | 冻结版本 | 许可证 / 校验 | 使用与退出路线 | +| --- | --- | --- | --- | +| Go | `1.26.5`(与 Sense 相同) | BSD-3-Clause;复用 §1.1 工具链与校验 | Bell 独立 Go module;升级时同时运行两个 module 的 test/vet/build | +| PostgreSQL / pgx | `17.10` / `github.com/jackc/pgx/v5 v5.10.0` | PostgreSQL License / MIT;module sum `h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=` | 复用 T-009 的 `database/sql` 边界;运行身份改用无 owner 权限的 `bell_runtime` 组 | +| JSON Schema | `github.com/santhosh-tekuri/jsonschema/v6 v6.0.2` | Apache-2.0;module sum `h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=` | 运行时校验 Draft 2020-12 与 format;退出时可替换 validator,但冻结 schema 和负向契约测试不变 | +| ULID | `github.com/oklog/ulid/v2 v2.1.2` | Apache-2.0;module sum `h1:IEclFb9JNvzYA6MW2SCxbLzcHTVsfqm3PrqGQJH5zec=` | 只封装在 Bell ID generator port 后;平台前缀仍为 `evt_`,替换实现不得改变格式或所有权 | + +T-015 不冻结 Brain→Bell transport,也不产生可部署 Bell API 二进制。内部 factory 接收不含 `id` 的候选事实,由 Bell 生成 ULID 后才形成最终 v0.1 事件;不得把该 Go 类型当成公共网络协议。 + ## 2. 外部项目边界 - MiBeeNvr:只用于 M0 隔离实验室、ONVIF兼容性和交互参考,不作为生产依赖。 @@ -99,6 +110,15 @@ go -C Sense build ./... go -C Sense run ./cmd/sense-api ``` +Bell 事件域基础单独执行(当前没有可启动 API): + +```powershell +go -C Bell mod download +go -C Bell test ./... +go -C Bell vet ./... +go -C Bell build ./... +``` + 直接验证: ```powershell @@ -116,7 +136,7 @@ python scripts/validate_harness_governance.py | Sense Go | `go -C Sense generate ./internal/mtx ./internal/controlapi`、`go -C Sense test ./...`、`go -C Sense vet ./...`、`go -C Sense build ./...` | ONVIF、存储、MediaMTX、对账或公共 API 变化 | T-006 使用 1 路指定实机 + 4 路独立合成源;T-007 才要求客户/借用/租赁的真实多路矩阵 | | PostgreSQL schema/repository | `python -m unittest discover -s tests -p "test_postgres_contract.py"`;Windows 本机再运行 `./scripts/test_postgres.ps1 -PgRoot D:\pgsql17` | migration、权限、配额判定或 PostgreSQL driver 变化 | 不需要摄像头;必须核对临时集群未使用现有 data 目录,现有 5432 listener 前后不变 | | Brain Python | 单元测试、类型/格式检查(命令待项目脚手架冻结) | mapper、判定状态机、模型接口变化 | 命中模型任务时用冻结数据集和目标硬件 | -| Bell Go/Web | 后端测试 + 前端 lint/test/build(命令待脚手架冻结) | schema、RBAC、预警状态机或公共 UI 变化 | P0 流程由产品/值班角色验收 | +| Bell Go/Web | 当前后端:`go -C Bell test ./...`、`go -C Bell vet ./...`、`go -C Bell build ./...`;前端命令待脚手架冻结 | schema、RBAC、预警状态机或公共 UI 变化 | P0 UI 流程由产品/值班角色验收;纯事件存储不需要 UI 人工验收 | | 容量/分片 | 任务内基准脚本;本地 16 路入口为 `./Sense/scripts/t014-capacity.ps1 -PgRoot D:\pgsql17` | 默认 16 路软件基线,以及后续 64/128 路分片里程碑 | 本地 16 路控制面可用独立合成源;真实多路、生产 SLA、64/128 路、AI/GPU、网络与存储必须使用目标环境分别验收 | 代码脚手架落地时必须把真实命令同步到本文、`init.ps1`/`init.sh`、`00-ai-start-here.md` 和 `current-state.md`。 diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 936be98..b879e16 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -48,7 +48,7 @@ Sense ── 视频流/触发信号 ──> Brain 1. Bell 持有站点、Area、配额与 `capture_policy`;首期在同一 PostgreSQL 实例内发布 `bell.site_quota_v1` 和 `bell.area_policy_v1` 两个版本化只读视图。T-009/T-010 已实现 Bell 源表/视图、最小权限和 Sense PostgreSQL repository;Sense 按 Area→Site 的固定 advisory-lock 顺序执行策略与配额准入并记录所用版本。未来分库必须发布新版本契约,不能静默改变 v1 语义。 2. Sense 维护设备期望态,通过 MediaMTX API 和对账器收敛实际态;PostgreSQL 多实例以数据库时钟短租约和 fencing token 领取 due row,过期 worker 不得提交结果。 3. Brain 消费视频与触发信号,产生符合 v0.1 的事件。 -4. Bell 做 schema 与代码级断言,生成平台 ULID,保存不可变事件。 +4. Bell 做 schema 与代码级断言,生成平台 ULID,保存不可变事件。T-015 已实现内部 candidate→final event factory、append-only PostgreSQL repository 和独立 outcome 事实;Brain→Bell transport、认证和公共 API 仍未冻结。 5. 规则命中后创建独立 Alert,先落库再投递,等待 ack 并按策略升级。 6. Bell 发起 pre-roll 证据回捞,Sense 提供切片接口。 7. 用户标记 outcome,反馈进入 Brain 的数据闭环。 @@ -89,6 +89,7 @@ T-014 已在单台 Windows 主机上用隔离 PostgreSQL、真实 Control API、 - `bell.site_quota_v1` 行缺失、数值越界、版本回退或读取失败只阻止视频设备新增/启用,不中断已有流;降低配额导致超限时不自动停用,后续准入返回稳定错误并产生运维信号。多 Sense 实例使用 PostgreSQL transaction-scoped advisory lock 串行化同 tenant/site 的计数与写入,不能用进程内锁替代。 - `bell.area_policy_v1` 缺失、非法、版本回退或读取失败时,PostgreSQL repository 拒绝相关新增/启用;`non_imaging_only` 允许非成像设备但拒绝具有 `video_capture` 的设备。已有设备保持原状态,策略冲突由 Bell 管理端显式迁移或取消。同库实时视图不以源记录年龄误判 freshness。 - 设备创建和期望态受理在本地事务内同时写脱敏 `sense.device_operation_outbox`;Outbox 失败回滚业务写入,相同期望态不增加 generation 但仍审计。异步 relay 尚未实现。 +- Bell 最终事件写入 `bell.events`;同平台 ID/同摘要仅视为幂等重放,同 ID/不同摘要拒绝。`bell_runtime` 只有 `SELECT/INSERT`,事件与 outcome 的 UPDATE/DELETE 另由数据库 trigger 拒绝;后续人工/自动 outcome 追加到独立表,不改写事件 payload。 - Brain 投递失败落本地队列重试,不阻塞实时推理主链路。 - Alert 先落库再投递,进程重启恢复未完成升级链。 - 值班排班发布前必须按 Site 时区校验班次空档、重叠、联系人停用和通道验证;排班以新版本和未来生效时间发布,不原地改写历史。交接班是进行中 Alert 的显式责任转移事件,不替代排班版本变更。 @@ -113,10 +114,10 @@ Sense/cmd + Sense/internal/{device,onvif,mtx,reconcile,orphan,metrics,probe,trig Brain/{pipeline,models,judge,emit,trigger,contracts} Bell/cmd + Bell/internal/{ingest,event,rule,alert,deliver,feedback,tenant,audit,store} Bell/{web,packs,contracts} -deploy/postgres/{001_roles.sql,...,011_privileges_reconcile_safety.sql,tests} +deploy/postgres/{001_roles.sql,...,013_privileges_bell_events.sql,tests} ``` -Sense 脚手架和 PostgreSQL `001`~`011` 已实现;Brain/Bell 应用目录仍是占位,真实服务必须由对应任务创建。 +Sense 脚手架和 PostgreSQL `001`~`013` 已实现;Bell 已有事件校验/不可变存储 Go 基础,但没有可部署 API 服务,Brain 仍为目录占位。 ## 10. 开发顺序 diff --git a/docs/06-tasks.md b/docs/06-tasks.md index 8aa2c51..a72bf72 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -36,6 +36,7 @@ - Brain 模型接口、判定内核和 v0.1 mapper。 - Bell 事件校验、不可变存储和 ULID。 +- T-015:建立 Bell Go 事件域基础,复制并校验冻结 v0.1 schema,由 Bell 生成平台 ULID,执行六项代码断言,并以 `bell_runtime` 最小权限保存不可变事件和 append-only outcome;不冻结 Brain transport 或公共 API。 - 规则引擎、场景包加载、预警状态机与双路径投递。 - 最小 Web/App 处置流程、RBAC 与审计。 - 现场误报基线和反馈队列。 diff --git a/docs/api.md b/docs/api.md index b31f3ea..1ec48ac 100644 --- a/docs/api.md +++ b/docs/api.md @@ -17,6 +17,8 @@ - 证据文件名只含事件 ID 与日期目录,不含 IP、端口、凭据或客户名。 - `sensors` 中恰有一个 primary,且其 `device_id` 与顶层一致。 +T-015 已实现 Bell 消费端的内部组装与存储边界:可信 ingress 先接收“不含平台 `id`”的候选事实,Bell 生成 `evt_` ULID 后形成最终 v0.1 对象,再执行 schema 与六项代码断言并不可变落库。该候选类型是 Bell 内部 port,不是 Brain 可依赖的 HTTP/消息总线协议;transport、认证和重放语义仍由后续任务冻结。 + ## 2. 跨系统接口状态 | 调用方 → 提供方 | 用途 | 当前约束 | 状态 | diff --git a/docs/current-state.md b/docs/current-state.md index 6005f90..0fb3fd5 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -4,17 +4,18 @@ ## 当前阶段 -- 阶段:M0 指定摄像头型号准入、M1“一实机 + 四合成源”软件闭环和 M2 本地 16 路批量收敛/稳定基线已通过;客户网络尚未提供,WireGuard T-013 后置,五条独立真实上游和生产 SLA 仍未验收。下一开发重点转向 M3 的 Bell 事件存储与审计 relay 边界。 -- 生产代码:Sense 已包含可构建进程、SQLite/PostgreSQL repository、Site/Area 准入、设备操作 Outbox、标准 ONVIF SOAP/WS-Security adapter、凭据引用、MediaMTX 生成客户端、Control API v1、对账/探活、数据库租约、孤儿只读扫描/受控命令、低基数指标和可重复 16 路容量脚本;Bell 管理服务/JWT、Outbox relay、WireGuard 和完整生产部署仍未实现。 +- 阶段:M0 指定摄像头型号准入、M1“一实机 + 四合成源”软件闭环和 M2 本地 16 路批量收敛/稳定基线已通过;M3 已建立 Bell 不可变事件存储基础。客户网络尚未提供,WireGuard T-013 后置,五条独立真实上游和生产 SLA 仍未验收。下一开发重点是 Sense Outbox → Bell 全局审计 relay。 +- 生产代码:Sense 已包含可构建进程、SQLite/PostgreSQL repository、Site/Area 准入、设备操作 Outbox、标准 ONVIF SOAP/WS-Security adapter、凭据引用、MediaMTX 生成客户端、Control API v1、对账/探活、数据库租约、孤儿只读扫描/受控命令、低基数指标和可重复 16 路容量脚本;Bell 已包含可构建 Go module、事件 v0.1 schema/语义校验、平台 ULID、不可变 PostgreSQL repository 和 append-only outcome,但仍没有可部署 API、管理服务/JWT、Outbox relay、规则/Alert 或 Web/H5。 - 默认容量:16 路;单站点本阶段上限 128 路,必须横向分片。 ## 仓库现实 -- `Sense/` 已有 Go module 与 `cmd/sense-api`;`Brain/`、`Bell/` 仍只有目录占位,Bell 的首个数据库源表/视图 migration 位于根目录 `deploy/postgres/`。 +- `Sense/` 已有 Go module 与 `cmd/sense-api`;`Bell/` 已有事件域 Go module 但没有 `cmd/bell-api`;`Brain/` 仍只有目录占位。Bell/Sense migration 统一位于根目录 `deploy/postgres/`。 - Sense 设备模型使用 `modality + capabilities`,SQLite 执行 v1 migration;视频配额默认 16、允许 1~128,17/128/129、新增/启用和“降低配额不关闭已有流”均有测试。 - T-009 冻结 PostgreSQL `17.10` 和 `pgx/v5 v5.10.0`,实现 `bell`/`sense` schema、NOLOGIN 权限角色、Bell Site 版本 trigger、`bell.site_quota_v1` 和 Sense PostgreSQL repository;同站点并发准入用事务级 advisory lock,配额缺失/越界/版本回退时失败关闭且不改变已有流。 - T-010 增量实现 `bell.areas`、`bell.area_policy_v1`、Area 版本观察和 `sense.device_operation_outbox`;`non_imaging_only` 拒绝成像设备创建/启用,失败不改变已有设备。设备创建/期望态受理与脱敏 Outbox 同事务,相同期望态不增加 generation 但仍审计。 -- Windows 隔离测试使用 `D:\pgsql17\bin` 启动随机回环端口临时集群,`001`~`011` migration 可重放、权限断言和 29 个真实 PostgreSQL 测试通过后自动清理;现有 `D:\pgsql17\data` 和 5432 服务未被读取、停止或修改。 +- Windows 隔离测试使用 `D:\pgsql17\bin` 启动随机回环端口临时集群,`001`~`013` migration 可重放;Sense repository、Bell event repository、权限、幂等冲突与不可变性测试通过后自动清理,现有 `D:\pgsql17\data` 和 5432 服务未被读取、停止或修改。 +- T-015 冻结 Bell Go 1.26.5、JSON Schema v6.0.2 和 ULID v2.1.2;Bell 拒绝上游自报平台 ID,在内部 candidate 组装后执行冻结 v0.1 schema 与六项语义断言。`bell_runtime` 只允许追加/读取 `bell.events`、`bell.event_outcomes`;Brain transport、整数事件 ID 与现有文本逻辑 ID 的跨系统映射、公共 API 和生产隐私 resolver 仍未冻结,不能把内部 factory 当成已上线入口。 - MediaMTX 固定为独立二进制 `v1.19.3`,官方 OpenAPI 已按 SHA-256 vendoring,并由固定 `oapi-codegen v2.8.0` 生成客户端;手写薄封装有 create/read/delete、幂等 ensure、探活和只返回名称的受限分页枚举测试。 - T-003 对账进度与指数退避持久化,覆盖取消和 SQLite 重启恢复;T-006 增加真实 ONVIF adapter、RTSP router、实验室播种/状态工具、故障代理和五路自动验收。T-012 的普通调和不枚举孤儿;独立 PostgreSQL 扫描默认只报告,未知归属永不删除。 - T-006 正式使用 1 台准入实机和 4 个独立合成 publisher 连续观察 `1806.6 s` / 180 次采样,四类恢复均通过,最大与最终 `unconverged` 均为 0;详细证据见 `docs/research/sense-5-stream-integration.md`。 @@ -43,6 +44,14 @@ Windows: go -C Sense run ./cmd/sense-api ``` +Bell 事件域基础验证(当前没有可启动 API): + +```powershell +go -C Bell test ./... +go -C Bell vet ./... +go -C Bell build ./... +``` + 跨平台直接验证: ```powershell @@ -77,12 +86,12 @@ Sense 默认监听 `127.0.0.1:8080`,提供 `/healthz`、`/readyz` 运维探针 - 人脸方向已延后至 M5 的 S4 成人园区候选试点;必要性/PIP 影响评估、单独同意与替代方式、合法底库来源和删除流程未完成,阻塞人脸能力上线。 - 短信/语音具体供应商未选;生产前必须选定两条独立投递路径并验证故障切换。 - Python/Savant 的精确版本、目标硬件和 Bell 前端栈尚未冻结;Sense M1 的 Go、SQLite driver、MediaMTX、生成器及生成运行时版本已在 T-003 冻结,PostgreSQL/pgx 版本已在 T-009 冻结。 -- 本机现有 PostgreSQL 5432 实例使用 SCRAM 且当前开发进程没有管理员密码;T-009~T-014 不绕过认证,自动验收使用隔离临时集群。向共享/生产实例安装 migration 前仍需管理员私下提供专用数据库、登录角色、外部 Control API 安全文件与备份方案。 +- 本机现有 PostgreSQL 5432 实例使用 SCRAM 且当前开发进程没有管理员密码;T-009~T-015 不绕过认证,自动验收使用隔离临时集群。向共享/生产实例安装 migration 前仍需管理员私下提供专用数据库、最小权限登录角色、外部 Control API 安全文件与备份方案。 - 代码知识图谱在无业务代码阶段可能为空;工具不可用时使用 `rg` 处理文档与配置。 ## 下一步 -客户网络仍未提供,T-013 WireGuard 继续后置。下一项建议拆分并冻结 M3 的 Bell 不可变事件接收/存储,以及 Sense 设备操作 Outbox 到 Bell 全局审计的幂等 relay 协议与实现;两者不要在一个任务中混写。客户授权、借用或租赁条件具备后再执行 T-007 五条独立真实上游现场门禁。T-014 只解除本地默认 16 路软件基线缺口,不解除 T-007/T-013,也不形成真实多路或生产 SLA 承诺。 +客户网络仍未提供,T-013 WireGuard 继续后置。Bell 不可变事件存储已由 T-015 落地;下一项是独立冻结并实现 Sense 设备操作 Outbox 到 Bell 全局审计的幂等 relay,不把 Brain 事件 transport、规则或 Alert 混入。客户授权、借用或租赁条件具备后再执行 T-007 五条独立真实上游现场门禁。T-014/T-015 不解除 T-007/T-013,也不形成真实多路或生产 SLA 承诺。 ## 已知风险 diff --git a/docs/raw/contracts/README.md b/docs/raw/contracts/README.md index fd2733f..c8bcaab 100644 --- a/docs/raw/contracts/README.md +++ b/docs/raw/contracts/README.md @@ -85,7 +85,7 @@ mapper 的完整实现规格。左列出处见《07》§1。 已实测(`REJECT` = schema 能拦,`ACCEPT` = 拦不住): -schema **能**拦:省略必填键 / 非法 ULID / 顶层多余字段 / outcome 越界 / `config_version` 空串 / `snapshot_uris` 空数组 / 负 latency / kind 大写。 +schema **能**拦:省略必填键 / 非法 ULID / 顶层多余字段 / outcome 越界 / `config_version` 空串 / 负 latency / kind 大写。`snapshot_uris` 空数组对非成像事件是合法值,见修订记录、雷达示例和 schema 的 `minItems: 0`。 schema **拦不住**,需在 mapper 与平台入口各加一道断言: diff --git a/docs/tasks/T-015.md b/docs/tasks/T-015.md index 618c620..3460907 100644 --- a/docs/tasks/T-015.md +++ b/docs/tasks/T-015.md @@ -3,12 +3,12 @@ id: T-015 title: 建立 Bell 事件 v0.1 校验与不可变 PostgreSQL 存储 phase: 3 deps: [T-014] -status: TODO +status: DONE created: 2026-08-10 issue: 51 -context_ref: null -claim_branch: null -work_branch: null +context_ref: 5b42f58f23255831067f44206849344909d74ee9 +claim_branch: claims/T-015 +work_branch: agent/codex/T-015 write_paths: - docs/tasks/T-015.md - Bell/ @@ -18,6 +18,7 @@ write_paths: - deploy/postgres/README.md - scripts/test_postgres.ps1 - tests/test_bell_event_contract.py + - tests/test_postgres_contract.py - docs/raw/contracts/README.md - docs/00-ai-start-here.md - docs/03-tech-stack.md @@ -83,3 +84,9 @@ M3 要求 Bell 首次真实消费冻结的事件契约 v0.1,生成平台 ULID ## 执行记录 - 2026-08-10:按 M3 建议拆出本任务;完成仓库/远端状态检查和 `./init.ps1` 基线,现有 58 个 Python 测试及 Sense generate/test/vet/build 全部通过。实现尚未开始。 +- 2026-08-10:冻结 Bell Go 1.26.5、`jsonschema/v6 v6.0.2`、`ulid/v2 v2.1.2`,复用 PostgreSQL 17.10 / pgx v5.10.0;建立 candidate→Bell ULID→最终 v0.1 schema/语义校验。三个冻结示例均通过,负向测试覆盖上游自报 ID、未知字段、时间/延迟、confidence、primary、证据脱敏、隐私拒绝/不可用和 1 MiB 上限。 +- 2026-08-10:新增 `bell.events` 与 `bell.event_outcomes`、不可变 trigger 和独立 NOLOGIN `bell_runtime`。repository 验证同 ID/同摘要幂等、同 ID/不同摘要冲突、outcome 仅追加,以及运行登录无 `UPDATE/DELETE/TRUNCATE`。校正事件 README 中与 schema/雷达示例冲突的“空 snapshot 数组会被拒绝”旧句,未修改冻结 schema。 +- 2026-08-10:`go -C Bell test ./...`、`go -C Bell vet ./...`、`go -C Bell build ./...` 通过;`python -m unittest discover -s tests -p "test_bell_event_contract.py" -v` 5/5 通过,`test_postgres_contract.py` 8/8 通过。 +- 2026-08-10:`./scripts/test_postgres.ps1 -PgRoot D:\pgsql17` 通过;PostgreSQL 17.10 临时集群将 `001`~`013` migration 重放两次,Sense/Bell 真实 repository 与权限断言通过,随机端口、临时目录均清理,现有 5432 listener 未改变。 +- 2026-08-10:更新根 `init.ps1`/`init.sh` 后运行 `./init.ps1` 通过:64 个 Python 测试、Sense generate/test/vet/build 和 Bell test/vet/build 全部成功。人工/设备/UI 验收不适用。 +- 边界:本任务没有可部署 Bell API;Brain transport、认证、整数事件 ID 与文本逻辑 ID 映射及生产隐私 resolver 仍待后续任务,内部 Go factory 不能被表述为已上线跨系统入口。 diff --git a/init.ps1 b/init.ps1 index cdb7b75..f194bf5 100644 --- a/init.ps1 +++ b/init.ps1 @@ -10,9 +10,9 @@ $ErrorActionPreference = "Stop" Set-Location -Path $PSScriptRoot -# Sense 使用锁定 Go toolchain/module;生成漂移、测试、vet 与构建均进入标准门禁。 -$InstallCmd = "go -C Sense mod download" -$VerifyCmd = "python scripts/validate_agent_context.py; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; python -m unittest discover -s tests -p 'test_*.py'; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; python scripts/validate_harness_governance.py; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Sense generate ./internal/mtx ./internal/controlapi; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; git diff --exit-code -- Sense/internal/mtx/generated/client.gen.go Sense/internal/controlapi/generated.gen.go; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Sense test ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Sense vet ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Sense build ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }" +# Sense/Bell 使用锁定 Go toolchain/module;生成漂移、测试、vet 与构建均进入标准门禁。 +$InstallCmd = "go -C Sense mod download; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Bell mod download" +$VerifyCmd = "python scripts/validate_agent_context.py; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; python -m unittest discover -s tests -p 'test_*.py'; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; python scripts/validate_harness_governance.py; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Sense generate ./internal/mtx ./internal/controlapi; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; git diff --exit-code -- Sense/internal/mtx/generated/client.gen.go Sense/internal/controlapi/generated.gen.go; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Sense test ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Sense vet ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Sense build ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Bell test ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Bell vet ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Bell build ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }" $StartCmd = "go -C Sense run ./cmd/sense-api" function Assert-Configured { diff --git a/init.sh b/init.sh index 9a8ac5c..9e49b98 100644 --- a/init.sh +++ b/init.sh @@ -12,9 +12,9 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$ROOT_DIR" -# Sense 使用锁定 Go toolchain/module;生成漂移、测试、vet 与构建均进入标准门禁。 -INSTALL_CMD=(go -C Sense mod download) -VERIFY_CMD=(bash -lc "python3 scripts/validate_agent_context.py && python3 -m unittest discover -s tests -p 'test_*.py' && python3 scripts/validate_harness_governance.py && go -C Sense generate ./internal/mtx ./internal/controlapi && git diff --exit-code -- Sense/internal/mtx/generated/client.gen.go Sense/internal/controlapi/generated.gen.go && go -C Sense test ./... && go -C Sense vet ./... && go -C Sense build ./...") +# Sense/Bell 使用锁定 Go toolchain/module;生成漂移、测试、vet 与构建均进入标准门禁。 +INSTALL_CMD=(bash -lc "go -C Sense mod download && go -C Bell mod download") +VERIFY_CMD=(bash -lc "python3 scripts/validate_agent_context.py && python3 -m unittest discover -s tests -p 'test_*.py' && python3 scripts/validate_harness_governance.py && go -C Sense generate ./internal/mtx ./internal/controlapi && git diff --exit-code -- Sense/internal/mtx/generated/client.gen.go Sense/internal/controlapi/generated.gen.go && go -C Sense test ./... && go -C Sense vet ./... && go -C Sense build ./... && go -C Bell test ./... && go -C Bell vet ./... && go -C Bell build ./...") START_CMD=(go -C Sense run ./cmd/sense-api) ensure_configured() { diff --git a/scripts/test_postgres.ps1 b/scripts/test_postgres.ps1 index 83cdeb8..610f8a6 100644 --- a/scripts/test_postgres.ps1 +++ b/scripts/test_postgres.ps1 @@ -49,6 +49,8 @@ $hadSenseDSN = Test-Path Env:YOVISION_TEST_POSTGRES_DSN $previousSenseDSN = if ($hadSenseDSN) { (Get-Item Env:YOVISION_TEST_POSTGRES_DSN).Value } else { $null } $hadAdminDSN = Test-Path Env:YOVISION_TEST_POSTGRES_ADMIN_DSN $previousAdminDSN = if ($hadAdminDSN) { (Get-Item Env:YOVISION_TEST_POSTGRES_ADMIN_DSN).Value } else { $null } +$hadBellDSN = Test-Path Env:YOVISION_TEST_BELL_POSTGRES_DSN +$previousBellDSN = if ($hadBellDSN) { (Get-Item Env:YOVISION_TEST_BELL_POSTGRES_DSN).Value } else { $null } function Invoke-Checked { param( @@ -87,17 +89,23 @@ try { '008_control_api.sql', '009_privileges_control_api.sql', '010_reconcile_safety.sql', - '011_privileges_reconcile_safety.sql' + '011_privileges_reconcile_safety.sql', + '012_bell_events.sql', + '013_privileges_bell_events.sql' )) { Invoke-Checked $psql '-X' '-v' 'ON_ERROR_STOP=1' '-d' $adminDatabaseDSN '-f' (Join-Path $repoRoot "deploy\postgres\$name") } } Invoke-Checked $psql '-X' '-v' 'ON_ERROR_STOP=1' '-d' $adminRootDSN '-c' 'CREATE ROLE yovision_t012_sense LOGIN IN ROLE sense_app' + Invoke-Checked $psql '-X' '-v' 'ON_ERROR_STOP=1' '-d' $adminRootDSN '-c' 'CREATE ROLE yovision_t015_bell LOGIN IN ROLE bell_runtime' Invoke-Checked $psql '-X' '-v' 'ON_ERROR_STOP=1' '-d' $adminDatabaseDSN '-f' (Join-Path $repoRoot 'deploy\postgres\tests\assertions.sql') $env:YOVISION_TEST_POSTGRES_DSN = $senseDSN $env:YOVISION_TEST_POSTGRES_ADMIN_DSN = $adminDatabaseDSN + $bellDSN = "postgres://yovision_t015_bell@127.0.0.1:$port/${databaseName}?sslmode=disable" + $env:YOVISION_TEST_BELL_POSTGRES_DSN = $bellDSN Invoke-Checked 'go' '-C' (Join-Path $repoRoot 'Sense') 'test' './internal/store' '-run' '^TestPostgres' '-count=1' + Invoke-Checked 'go' '-C' (Join-Path $repoRoot 'Bell') 'test' './internal/store' '-run' '^TestPostgres' '-count=1' } finally { if ($started) { @@ -132,6 +140,12 @@ finally { else { Remove-Item Env:YOVISION_TEST_POSTGRES_ADMIN_DSN -ErrorAction SilentlyContinue } + if ($hadBellDSN) { + $env:YOVISION_TEST_BELL_POSTGRES_DSN = $previousBellDSN + } + else { + Remove-Item Env:YOVISION_TEST_BELL_POSTGRES_DSN -ErrorAction SilentlyContinue + } $after5432 = @( Get-NetTCPConnection -State Listen -LocalPort 5432 -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique | diff --git a/tests/test_bell_event_contract.py b/tests/test_bell_event_contract.py new file mode 100644 index 0000000..2cc3198 --- /dev/null +++ b/tests/test_bell_event_contract.py @@ -0,0 +1,63 @@ +"""Static contract checks for T-015 Bell immutable event storage.""" + +from pathlib import Path +import unittest + + +ROOT = Path(__file__).resolve().parents[1] + + +def text(path: str) -> str: + return (ROOT / path).read_text(encoding="utf-8") + + +class BellEventContractTests(unittest.TestCase): + def test_runtime_schema_copy_matches_frozen_contract(self) -> None: + self.assertEqual( + (ROOT / "docs/raw/contracts/event-v0.1.schema.json").read_bytes(), + (ROOT / "Bell/contracts/event-v0.1.schema.json").read_bytes(), + ) + + def test_bell_module_freezes_reviewed_dependencies(self) -> None: + module = text("Bell/go.mod") + self.assertIn("github.com/jackc/pgx/v5 v5.10.0", module) + self.assertIn("github.com/oklog/ulid/v2 v2.1.2", module) + self.assertIn("github.com/santhosh-tekuri/jsonschema/v6 v6.0.2", module) + + def test_event_migration_is_append_only_and_not_limited_to_sixteen(self) -> None: + migration = text("deploy/postgres/012_bell_events.sql").lower() + privileges = text("deploy/postgres/013_privileges_bell_events.sql").lower() + for marker in ( + "create table if not exists bell.events", + "create table if not exists bell.event_outcomes", + "bell.reject_immutable_change", + "insert into bell.schema_migrations(version) values (3)", + ): + self.assertIn(marker, migration) + self.assertNotIn("limit 16", migration) + self.assertIn("grant select, insert on table bell.events, bell.event_outcomes to bell_runtime", privileges) + self.assertNotIn("grant update", privileges) + self.assertNotIn("grant delete", privileges) + self.assertNotIn("grant truncate", privileges) + + def test_factory_owns_id_and_checks_all_semantic_boundaries(self) -> None: + source = text("Bell/internal/event/event.go") + for marker in ( + "upstream_id_forbidden", + "latency_inconsistent", + "confidence_forbidden", + "evidence_unsafe", + "primary_sensor_invalid", + "privacy_unavailable", + "privacy_denied", + ): + self.assertIn(marker, source) + + def test_contract_document_no_longer_rejects_non_imaging_empty_snapshot(self) -> None: + readme = text("docs/raw/contracts/README.md") + self.assertNotIn("`snapshot_uris` 空数组 / 负 latency", readme) + self.assertIn("`snapshot_uris` 空数组对非成像事件是合法值", readme) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_postgres_contract.py b/tests/test_postgres_contract.py index 9806f99..b4743a1 100644 --- a/tests/test_postgres_contract.py +++ b/tests/test_postgres_contract.py @@ -58,6 +58,8 @@ class PostgresContractTests(unittest.TestCase): "009_privileges_control_api.sql", "010_reconcile_safety.sql", "011_privileges_reconcile_safety.sql", + "012_bell_events.sql", + "013_privileges_bell_events.sql", ], names, ) @@ -71,6 +73,15 @@ class PostgresContractTests(unittest.TestCase): ) self.assertNotRegex(text, r"\bpassword\b") + def test_bell_runtime_role_is_separate_from_migration_owner(self) -> None: + text = normalized(migration_text("012_bell_events.sql")) + self.assertIn( + "create role bell_runtime nologin nosuperuser nocreatedb nocreaterole noreplication", + text, + ) + self.assertNotIn("grant bell_app to bell_runtime", text) + self.assertIn("pg_has_role('bell_runtime', 'bell_app', 'member')", text) + def test_bell_quota_contract_and_version_are_enforced(self) -> None: text = normalized(migration_text("002_bell.sql")) expected_signature = (