[T-015] 建立 Bell 不可变事件存储 #53

Merged
ila merged 1 commits from agent/codex/T-015 into main 2026-08-10 23:54:28 +08:00
28 changed files with 1434 additions and 27 deletions
+4 -1
View File
@@ -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`。
-1
View File
@@ -1 +0,0 @@
+22
View File
@@ -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 服务。
+9
View File
@@ -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
+283
View File
@@ -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-<session>-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"
}
}
}
+19
View File
@@ -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
)
+33
View File
@@ -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=
+302
View File
@@ -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
}
+184
View File
@@ -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)
}
+149
View File
@@ -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
}
+120
View File
@@ -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")
}
}
+115
View File
@@ -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;
@@ -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;
+2 -1
View File
@@ -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 的表和函数没有权限。
+23 -1
View File
@@ -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;
+4 -1
View File
@@ -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)。
+21 -1
View File
@@ -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`。
+4 -3
View File
@@ -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. 开发顺序
+1
View File
@@ -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 与审计。
- 现场误报基线和反馈队列。
+2
View File
@@ -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. 跨系统接口状态
| 调用方 → 提供方 | 用途 | 当前约束 | 状态 |
+15 -6
View File
@@ -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 承诺。
## 已知风险
+1 -1
View File
@@ -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 与平台入口各加一道断言:
+11 -4
View File
@@ -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 不能被表述为已上线跨系统入口。
+3 -3
View File
@@ -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 {
+3 -3
View File
@@ -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() {
+15 -1
View File
@@ -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 |
+63
View File
@@ -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()
+11
View File
@@ -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 = (