--- id: T-019 title: 建立 Brain 到 Bell 的可靠事件入站链路 phase: 3 deps: [T-015, T-017] status: DONE created: 2026-08-11 issue: 67 context_ref: b7fe44eeb0caf8d41abef365ec2100c74373321f claim_branch: claims/T-019 work_branch: agent/codex/T-019 write_paths: - docs/tasks/T-019.md - docs/contracts/ - docs/raw/contracts/README.md - Brain/ - docs/design/brain/index.html - Bell/ - deploy/postgres/ - scripts/test_postgres.ps1 - docs/00-ai-start-here.md - docs/03-tech-stack.md - docs/04-architecture.md - docs/06-tasks.md - docs/api.md - docs/current-state.md - tests/test_brain_event_ingress_contract.py - tests/test_postgres_contract.py --- ## 问题 / 背景 T-017 已能产生匿名区域事件候选,T-015 已能在 Bell 内部校验并保存不可变事件,但两者之间没有可部署的传输、认证、逻辑身份映射或跨重启幂等确认。直接把 T-017 的 100 项内存事件环当作投递队列,会在进程退出、网络中断或 Bell 已接收但 Brain 尚未确认时丢失或重复事件;让 Brain 自报 Bell 平台 `id` 又会破坏冻结事件契约的所有权。 本任务建立一个默认关闭、可本地联调的可靠纵切:Brain 把完整 v0.1 候选先写入 SQLite Outbox,再通过内部 HMAC HTTP 端点投递;Bell 在一个 PostgreSQL 事务中验证身份、Area 隐私策略、候选语义并保存事件与永久来源收据。该纵切为后续证据切片、规则、Alert 与 ack 提供可信事件入口,但不提前实现这些能力。 ## 关联需求与交互(如适用) - 用户故事:US-005 的“判定结果形成事件”基础链路;不在本任务内实现业务预警、处置或误报反馈。 - 交互清单:不适用。本任务是内部事件 ingress 和后台可靠投递,不新增客户页面或公共路由。 - 相关页面 / 路由:新增内部 `POST /internal/v1/event-candidates`;T-017 `/brain-demo` 只增加脱敏投递状态,不把内部地址、key 或 payload 暴露给页面。 ## 方案 1. 在 `docs/contracts/brain-event-ingress-v1.openapi.json` 冻结单事件 envelope、响应、稳定错误码和 HMAC 规则。请求包含 `schema_version=1`、`producer_id` 与不含 Bell `id` 的完整 event v0.1 candidate;Brain 提供 `source_event_id`,Bell 生成并返回平台 `evt_` ULID。正文上限 1 MiB,deadline 10 秒。 同步把 T-017 已产生的 `zone_entry` 登记到 event v0.1 kind 注册表;不修改冻结 JSON Schema 或既有 kind 语义。 2. 复用 T-016 已验证的 HMAC-SHA256 canonical 形式:method、path、Unix 秒、随机 nonce、body SHA-256 以换行连接;使用独立的仓库外 key 文件,并把每个 key 绑定到一个 `producer_id`。允许 300 秒时钟偏差,nonce 防重收据保留 600 秒。回环可用 HTTP,非回环必须 HTTPS;事件 key 与审计 relay key 不混用。 3. 新增 Bell 所有的 `event_ingress_bindings`,把 `(producer_id, tenant_id, site_id, device_id)` 数字事件身份绑定到现有 Bell tenant/site/device/area 逻辑身份和 `video` 模态。入站必须 fail closed:绑定缺失、禁用、站点/Area 已删除、设备或 Area 归属不一致、非视频或 `capture_policy != video_allowed` 均拒绝。首版只提供受控 SQL 配置方式,不新增公共绑定管理 API。 4. 新增永久 append-only `event_ingress_receipts`,以 `(producer_id, source_event_id)` 唯一,保存 canonical candidate SHA-256 与 Bell event ID;另用短期 `event_ingress_nonces` 防请求重放。Bell 用单事务和事务级 advisory lock 完成 nonce、来源收据、事件创建与响应:同来源且同 payload 返回原 Bell ID 和 `duplicate`,同来源不同 payload 返回 `source_event_conflict`,不得产生第二条事件。 5. `bell-api` 增加默认关闭的事件 ingress 配置和独立 handler。handler 使用现有 event factory、数据库隐私策略与证据授权校验;候选无法通过 schema、代码级语义或隐私断言时返回不可重试 4xx,数据库暂时失败返回可重试 503。Bell 事件和来源收据无 UPDATE/DELETE 权限;只有 nonce 表允许按 TTL 清理。 6. Brain 使用 Python 标准库实现 v0.1 mapper、SQLite Outbox、HTTP client 和 worker,不新增生产 ML 或消息总线依赖。启用配置只从仓库外绝对路径 JSON 读取,secret 另从仓库外文件读取;SQLite 文件也必须是仓库外绝对路径。默认关闭时 T-017 行为不变。 7. mapper 将 `EventCandidate` 转为完整 v0.1 candidate,不发送 `source_ref`、摄像头 URI、凭据或 Bell 平台 `id`。fixture 明确映射为 `outcome=test/outcome_source=auto`,真实源先保持 `outcome=unknown`;主传感器固定为已绑定的视频设备,区域、track、配置版本和候选版本只进入契约允许字段。 8. Outbox 先持久化后暴露成功,WAL 模式,有界待处理量 10,000、单 payload 1 MiB、1~300 秒指数退避、最多 100 次尝试。`accepted/duplicate` 标记 delivered;稳定校验/冲突进入 dead letter;网络、5xx 和认证故障重试直至预算耗尽。Bell 成功后 Brain 在本地 ack 前崩溃,重启重投必须得到同一个 Bell ID。 9. 添加 Python mapper/outbox/client/worker/运行时测试、Go auth/handler/store 测试、OpenAPI 静态契约测试,以及隔离 PostgreSQL migration replay、最小权限、并发幂等和重启重试联调。代码图 MCP 本轮不可用时,将定向读取/`rg` 的降级和实际验证结果记录在执行证据中。 ## 不可变约束 - 阈值 / 数值边界:正文与单 Outbox payload 最大 1 MiB;HTTP deadline 10 秒;时钟偏差 300 秒;nonce 保留至少 600 秒;Brain 待投递上限 10,000,重试 1~300 秒、最多 100 次。默认站点 16 路、可扩展 128 路不变,这些投递阈值不得成为设备路数硬上限。 - 判定式 / 状态转换:`queued -> delivering -> delivered | dead_letter`;只有 Bell `accepted/duplicate` 可进入 delivered。`(producer_id, source_event_id, candidate_hash)` 相同返回原 Bell ID;来源相同但 hash 不同永久冲突。事件、来源收据 append-only,Bell 平台 ID 只能由 Bell 生成。 - 安全边界:key、连接串、Brain 配置、SQLite Outbox、摄像头 URI/凭据和真实事件数据都在仓库外;非回环必须 HTTPS;producer 不能自报未绑定身份;未知/删除/策略拒绝必须 fail closed。响应、日志、页面和提交物不得回显 secret、完整 URI、原始 payload 或客户信息。 - 既有契约:最终事件必须严格符合 `docs/raw/contracts/event-v0.1.schema.json` 与语义 README;未知顶层字段拒绝,只能通过 `ext` 扩展。Sense/Bell 现有逻辑身份、Area `capture_policy`、T-015 Bell ULID 与不可变存储、T-016 审计 relay 均保持兼容;事件 ingress 使用独立端点、表和 key。 ## 验收要点 - 任务相关验证:`python -m unittest discover -s Brain/tests -p "test_*.py" -v`、`python -m compileall -q Brain`、`go -C Bell test ./...`、`go -C Bell vet ./...`、`go -C Bell build ./...`、`python -m unittest discover -s tests -p "test_brain_event_ingress_contract.py" -v`、`./scripts/test_postgres.ps1`。 - 完整门禁:运行 `./init.ps1`、三项文档治理基线与 `git diff --check`。PostgreSQL migration 必须从空库执行两遍且旧 migration 指纹不漂移;并发同源投递只落一条 event 和一条永久 receipt,运行角色不能更新/删除 event 或 receipt。 - 人工 / 设备验收:不需要新增摄像头或目标 GPU。使用 T-017 synthetic fixture 和本机隔离 PostgreSQL 完成端到端 smoke:首次返回 accepted、模拟 ack 前崩溃后重投返回 duplicate 且 Bell ID 相同;伪造签名、过期时间、重放冲突、未绑定身份和 Area 隐私拒绝均按契约失败。结果不构成真实多路、算法效果或生产 SLA。 - 构建产物:Bell `bell-api` 可执行程序与 Brain 源码 worker;OpenAPI、migration 和仓库外配置/key 示例写入各 README。不得提交运行时数据库、真实 key 或事件样本。 ## 边界(不改什么) 不实现录像/证据切片、对象存储、业务规则、Alert、ack/升级、误报反馈、公共 JWT/OIDC、绑定管理 UI/API、消息总线、生产模型、GPU pipeline 或 64/128 路压测;不修改 `_reference/`、MiBeeNvr、MediaMTX 数据面或冻结 event v0.1 文件。T-016 审计链路保持独立。 ## 协作约束 - 责任 Agent:codex。 - 唯一写入者:codex。 - 委派:不启用。 - Gitea:Issue、`context_ref`、claim 与工作分支在双向映射及 dispatcher 分配后回填。 任何新增写路径先检查与其他活跃任务是否重叠;同一时刻只有一个 Agent 修改本任务的 `write_paths`。 ## 执行记录 ### 2026-08-11 任务定义 - T-019 独立冻结 Brain→Bell 业务事件身份、持久重试、认证和幂等 ingress;不复用 T-017 内存事件环,也不扩张到规则、Alert 或证据链。 - 当前会话未暴露 codebase-memory MCP 图工具,代码发现按仓库规则降级为定向读取与 `rg`;任务定义前主分支 `./init.ps1` 基线通过:72 项根测试、19 项 Brain 测试,以及 Sense/Bell generate/test/vet/build 全绿。 - 任务定义已合入默认分支并创建唯一 Gitea Issue #67;本映射提交合入后才添加 `status/todo` 并允许 dispatcher 分配。 ### 2026-08-11 领取与基线 - dispatcher `ila` 检查依赖和开放工单后,从默认分支 `b7fe44eeb0caf8d41abef365ec2100c74373321f` 创建并读回 `claims/T-019` 与 `agent/codex/T-019`;Issue #67 已分配给 `ila`,标签为 `status/doing`,结构化 CLAIM 评论与本文件 `write_paths` 一致。 - T-007 保持 `status/waiting`,不构成活跃写路径预留;本任务不启用委派。 ### 2026-08-11 实现与自动化验收 - 冻结并实现 `brain-event-ingress-v1` 单事件 envelope。Bell event key 独立于审计 key,并把 `key_id` 绑定到唯一 `producer_id`;两端共享 method/path/timestamp/nonce/body-hash 五行 HMAC-SHA256,允许 300 秒时钟偏差,正文上限 1 MiB,非回环必须 HTTPS。Brain client 禁用环境代理和重定向,避免内部事件被旁路转发。 - PostgreSQL 新增可重放 `016`~`017`:Bell 所有的数字→逻辑身份绑定引用现有 Site/Area/Sense Device;运行时只能读取设备 ID、Area 与 modality 列,不能读取 endpoint、credential、profile token 或 path。永久来源收据和事件不可更新/删除,只有 10 分钟 nonce 表允许清理。 - Bell 在相同事务中提交最终 event、`(producer_id,source_event_id,candidate_hash)` 永久收据和成功 nonce 响应;同来源/同 canonical candidate 返回原 Bell ID,不同 candidate 返回稳定冲突。隔离 PostgreSQL 测试以 8 个并发重投确认只生成 1 条 event/1 条来源收据,并验证 Area 改为 `non_imaging_only` 后新视频事件失败关闭。 - Brain 新增完整 v0.1-minus-id mapper、WAL/`synchronous=FULL` SQLite Outbox、HMAC client 和 worker。Outbox 首次打开绑定 producer/tenant/site/device,禁止换身份复用旧队列;候选持久化成功后才进入页面内存环。fixture 显式映射为 `outcome=test/outcome_source=auto`,URI/凭据/`source_ref` 不出 Brain。队列执行 1~300 秒退避、100 次/10,000 条边界,`accepted/duplicate` 才 delivered,稳定 4xx 进入 dead letter;租约崩溃恢复与 Bell 成功后本地未 ack 的重投均有测试。 - Brain 工程原型在既有“最近候选事实”卡片内展示脱敏 Outbox 状态与待投递/投递中/已送达/死信计数;以文字和状态点共同区分未启用、正常、重试和死信,且明确单条候选的“仅内存观察”或“Outbox 已持久化”。状态无变化时不重复触发 `aria-live`,页面不暴露 Bell 地址、key 文件或 SQLite 路径。 - `zone_entry` 已登记到 event v0.1 kind 注册表;冻结 JSON Schema 未修改。Bell/Brain/部署 README、API、架构、技术栈和当前状态均同步,未扩张到证据、规则、Alert、公共认证或生产模型。 - 最终门禁通过:`./init.ps1`(77 项根测试、30 项 Brain 测试及 Sense/Bell generate/test/vet/build);独立 `go -C Bell test/vet/build ./...`、Brain compileall、4 项跨语言 ingress 契约测试和三项治理校验;`./scripts/test_postgres.ps1` 在 PostgreSQL 17.10 临时集群将 `001`~`017` 重放两次并通过真实 repository、权限、并发幂等、隐私和不可变性验证,随机端口/临时目录已清理且现有 5432 listener 未改变;`git diff --check` 通过。 - 本任务不需要真实摄像头或目标 GPU;synthetic fixture mapper、SQLite 崩溃恢复、真实本地 HTTP HMAC client、Bell handler 和真实 PostgreSQL consumer 的联合自动化证据满足本地验收。结果不构成算法效果、真实多路或生产 SLA。