feat(bell): add immutable event store [T-015]
This commit is contained in:
@@ -1 +0,0 @@
|
||||
|
||||
@@ -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 服务。
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user