2026-08-07 22:24:20 +08:00
---
id : T-012
title : 补齐 Sense 对账安全闸、孤儿受控处置与多实例可观测性
phase : 2
deps : [ T-011]
2026-08-07 23:00:03 +08:00
status : DONE
2026-08-07 22:24:20 +08:00
created : 2026-08-07
2026-08-07 22:25:42 +08:00
issue : 43
2026-08-07 22:28:16 +08:00
context_ref : 25357723a09218bae58626a9934309abcda34899
claim_branch : claims/T-012
work_branch : agent/codex/T-012
2026-08-07 22:24:20 +08:00
write_paths :
- docs/tasks/T-012.md
- Sense/README.md
- Sense/cmd/sense-api/
- Sense/cmd/sense-orphan/
- Sense/internal/config/
- Sense/internal/metrics/
- Sense/internal/mtx/
- Sense/internal/orphan/
- Sense/internal/reconcile/
- Sense/internal/store/
- 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
- docs/runbooks/
- tests/test_postgres_contract.py
- tests/test_sense_reconcile_safety.py
---
## 问题 / 背景
T-003 已实现幂等调和与持久化退避,T-011 已完成 Control API、批量期望态和停用设备的精确 Path 删除,但生产 PostgreSQL 路径仍由每个 Sense 实例独立读取同一批 due row;多实例会重复探测、重复写 MediaMTX,并可能由过期 worker 覆盖新 worker 的完成结果。当前 MediaMTX 客户端也不枚举配置 Path,因此无法区分台账、媒体配置和历史归属之间的孤儿差异。
直接把“MediaMTX 中存在、当前设备表中不存在”的所有 Path 自动删除并不安全:MediaMTX 可能包含人工或其他系统管理的 Path,Sense 也没有历史归属证据。本任务先建立可证明的所有权、只读报告、比例安全闸和受控的一次性处置流程,并提供不含租户/设备高基数标签的 Prometheus 文本指标;它是进入 WireGuard 与 16 路批量基准前的 M2 生产安全基础,不等于容量验收。
## 关联需求与交互(如适用)
- 用户故事:US-001、US-002、US-008、US-009;本任务提供接入运维所需的调和与孤儿诊断,不新增最终 Bell 管理页面。
- 交互清单:IX-003、IX-004、IX-015、IX-016 的后端运维状态基础;HTML 原型不改,正式运维中心 API/UI 留给后续契约任务。
- 相关页面 / 路由:新增低基数 `/metrics` 运维端点和本地 `sense-orphan` 命令;不修改 T-008 冻结的 `/api/v1` Control API。
## 方案
1. 增加 PostgreSQL `010` /`011` migration,把 `sense.schema_migrations` 提升到 v5: `sense.reconcile_state` 增加 owner/token/expiry 租约列;新增 MediaMTX Path 历史归属、孤儿扫描租约、扫描报告/发现项和处置结果表,并补齐 `sense_app` 最小权限、PUBLIC 拒绝与启动前置检查。migration 从现有视频设备脱敏回填 Path 归属,不保存 endpoint、credential 或 source URI。
2. 将调和 repository port 改为“领取 due row → 每项开工前续租 → 持 token 完成/失败”的 fencing 模型。PostgreSQL 使用短事务、`FOR UPDATE SKIP LOCKED` 和随机 claim token;完成、失败和续租必须同时匹配 device、owner、token 且租约未被新实例接管。SQLite 保持单进程开发语义,不宣称跨进程互斥。
3. 每项外部调和使用短于租约的 deadline;批量领取后逐项续租,等待期间已被其他实例接管的项只记录 `lease_lost` ,不得执行 MediaMTX/ONVIF 变更。启用设备成功收敛时在同一 PostgreSQL 完成事务中刷新其 Path 历史归属;停用设备的既有精确删除仍不经过孤儿批量逻辑。
4. 扩展 MediaMTX 薄客户端,以官方分页 API 枚举 Path 名称;循环设置页数上限、重复页保护和缺失字段校验,只返回排序去重后的名称,永不返回或记录 source URI。现有 `EnsurePath` /精确 `DeletePath` 语义不变。
5. 新增 PostgreSQL 专用孤儿扫描器并在 `sense-api` 中默认按 60 秒执行只读扫描。单例扫描使用带 fencing token 的数据库租约;一次报告将 MediaMTX 配置分为:仍被设备当前占用、Sense 历史拥有但已失配的 `owned_stale` 、从未有 Sense 归属证据的 `unowned` 。`unowned` 永远只报告;扫描失败不产生可执行快照。
6. 新增本地运维命令 `sense-orphan` : `report` 只产生脱敏 JSON 摘要和 15 分钟有效的 scan ID;`apply` 必须给出该 scan ID、合法 actor 和精确确认文本。执行前重新枚举 MediaMTX、重新读取所有权并重新计算安全闸,只处理仍为 `owned_stale` 的快照项;每项结果持久化,重复 apply 幂等。
7. 孤儿删除安全闸固定为 `候选数 * 100 <= 当前 MediaMTX 配置 Path 总数 * 10` ,并同时要求候选数 1~128、报告未超过 15 分钟、当前总数大于 0、当前候选是报告候选的子集。任何条件失败均整批不删除;不提供 force/bypass。删除逐项执行,部分失败可重试,但一次运行不得扩大目标集合。
8. 新增无第三方依赖的 Prometheus/OpenMetrics 文本 handler,暴露构建/实例信息、调和 run/item/lease-lost/时长/未收敛汇总,以及孤儿扫描 observed/owned-stale/unowned、blocked/deleted/failed 汇总。标签只使用固定结果枚举和经过格式校验的 instance ID,不使用 tenant、Site、device、Path、URI 或错误正文。
9. 更新 Sense 启动与文档:生成进程级 instance ID,也允许 `SENSE_INSTANCE_ID` 显式指定;配置调和租约/操作 deadline、孤儿只读扫描开关/周期和指标开关。默认 SQLite 继续可运行;孤儿持久化/处置只在 PostgreSQL v5 上启用。修正当前状态中“T-011 公共设备 API 尚未实现”的过期描述。
## 不可变约束
- 阈值 / 数值边界:调和租约默认 30 秒,单项 operation deadline 默认 20 秒且必须严格小于租约;调和 batch 最大 128。孤儿扫描默认 60 秒;报告有效期固定 15 分钟;单次处置 1~128 项;删除比例最多 10%,使用整数交叉相乘,禁止浮点边界漂移。
- 判定式 / 状态转换:只有 `media_path_ownership` 有历史记录、且当前没有任何视频设备继续声明同一 `(device_id,path_name)` 的运行时 Path 才是 `owned_stale` 。未归属 Path 是 `unowned` ,永不由 Sense 删除。过期或失配 fencing token 不得更新调和完成/失败,也不得产生可执行扫描报告。
- 安全边界:默认和周期任务只读扫描,绝不自动 apply;`apply` 必须二次确认并重新取运行时/数据库快照。比例闸、快照时效、上限、所有权或确认任一失败时零删除;不提供环境变量、flag 或隐藏接口绕过。完整 URI、endpoint、credential、profile token、DSN、认证 token 和错误响应正文不得进入指标、日志、报告或审计表。
- 既有契约:不修改 Sense Control OpenAPI v1、设备审计 v1/v2 和 7 个业务 endpoint;停用设备按精确台账 Path 幂等删除的行为保持。`site.max_video_channels` 默认 16、上限 128,不把 16 写成业务或数组硬上限。
- 多实例边界:PostgreSQL v5 才提供调和/孤儿租约语义;SQLite 只保留本地单实例开发,不得通过 SQLite 测试宣称生产多实例安全。租约不是长期锁,所有外部调用必须可取消并受 deadline 限制。
## 验收要点
- 任务相关验证:Go 单元测试覆盖 MediaMTX 多页/重复页/脱敏失败、租约失效时零副作用、两个 PostgreSQL store 并发只领取一次、租约过期接管与旧 token fencing、归属回填、三类孤儿判定、10% 边界、15 分钟过期、二次快照收窄、未知 Path 不删除、部分失败幂等和 metrics 无敏感/高基数标签。
- PostgreSQL 门禁:`./scripts/test_postgres.ps1 -PgRoot D:\pgsql17` 连续重放 `001` ~ `011` 、执行权限/约束断言和全部 `TestPostgres*` ;随机回环临时集群自动清理,不读取、停止或修改现有 `D:\pgsql17\data` /5432 实例。
- 完整门禁:`./init.ps1` 、三条 Python 治理命令、`go -C Sense test ./...` 、`go -C Sense vet ./...` 、`go -C Sense build ./...` 、`go test -race ./internal/metrics ./internal/mtx ./internal/orphan ./internal/reconcile ./internal/store` 、`git diff --check` 全部通过。
- 人工 / 设备验收:不需要摄像头、GPU 或客户现场;使用 fake MediaMTX 与隔离 PostgreSQL 验证。真实五路与容量结论仍由 T-007/后续 16 路基准提供。
- 构建产物:`sense-api` 的 `/metrics` 、`sense-orphan` 本地命令、PostgreSQL v5 migration、运维 runbook、测试和同步文档;不生成或提交任何私有配置。
## 边界(不改什么)
不实现 Bell 运维 UI/RBAC/JWT、WireGuard、Outbox relay、设备删除 API、跨 MediaMTX 分片编排、16 路容量压测、自动修复 `unowned` Path 或 T-007 现场验收;不修改 MediaMTX 生成代码、冻结 OpenAPI/审计契约和已确认 Sense/Bell HTML 原型。指标只建立进程内 Prometheus scrape 面,不引入 Grafana dashboard、OpenTelemetry SDK 或新的第三方依赖。
## 协作约束
- 责任 Agent:由 dispatcher 分配。
- 唯一写入者:同责任 Agent。
- 委派:默认不启用。
- Gitea:任务规格先进入默认分支,再创建唯一 Issue 并回填编号;领取时记录 `context_ref` 、claim / 工作分支和全部允许写路径。
任何新增写路径先检查与其他活跃任务是否重叠;同一时刻只有一个 Agent 修改本任务的 `write_paths` 。T-007 虽处于 waiting,但其未来写路径包含 `Sense/` ;T-012 活跃期间不得同时恢复 T-007。
## 执行记录
2026-08-07 23:00:03 +08:00
### 2026-08-07 完成多实例对账安全与孤儿受控处置
- PostgreSQL schema 提升到 v5: `010` /`011` 为 due row 增加 owner/token/expiry fencing,新增不含 source URI 的 MediaMTX Path 历史归属、孤儿扫描租约、15 分钟报告、发现项和处置审计,并补齐 `sense_app` 最小权限与 PUBLIC 拒绝;启动会拒绝旧 schema 或越权运行角色。
- 调和器改为 `FOR UPDATE SKIP LOCKED` 批量领取、逐项续租、短于租期的外部调用 deadline 和带 token 的完成/失败;数据库时钟判定租约,旧 worker 不能覆盖接管者结果。成功启用在同一完成事务中刷新 Path 归属;SQLite 明确保留单进程开发语义。
- MediaMTX 薄客户端新增只返回排序去重 Path 名称的有界分页枚举,包含重复页保护且不读取/返回 source;独立孤儿扫描把差异分为 `owned_stale` 与永不删除的 `unowned` 。周期任务默认只报告,人工 `sense-orphan apply` 必须使用 15 分钟内 scan ID、合法 actor 和精确确认文本,并在删除前重取库存/归属。
- 处置安全闸固定为 1~128 项且不超过当前 Path 的 10%,使用整数交叉相乘,无 force/bypass;目标只能从原快照收窄。逐项结果可审计和幂等重试,数据库外键/约束也拒绝为 `unowned` 写入删除记录。
- 新增无第三方运行时依赖的 `/metrics` :只使用构建/实例及固定结果枚举标签,覆盖调和 run/item/lease-lost/时长/未收敛和孤儿扫描/阻断/删除汇总;tenant、Site、device、Path、URI 与错误正文均不进入标签。
- `./scripts/test_postgres.ps1 -PgRoot D:\pgsql17` 通过:隔离 PostgreSQL 17.10 临时集群连续重放 `001` ~ `011` , SQL 权限/约束断言及 29 个 `TestPostgres*` 全绿,覆盖两个 store 并发唯一领取、租约接管/旧 token fencing、归属记录、扫描租约、未知 Path 数据库拒删和处置幂等;随机端口实例已停止并清理,现有 `D:\pgsql17\data` /5432 未被读取、停止或修改。
- `./init.ps1` 通过;Python 治理/契约测试共 52 项通过,T-012 定向静态契约 12 项通过;全部 Go 测试、生成漂移、`go vet` 、`go build` 与 `git diff --check` 通过;`go test -race ./internal/metrics ./internal/mtx ./internal/orphan ./internal/reconcile ./internal/store` 通过。不需要摄像头/GPU,本结果不解除 T-007,也不形成 16/128 路容量承诺。
2026-08-07 22:28:16 +08:00
### 2026-08-07 领取任务
- dispatcher `ila` 将 Issue #43 分配给 `codex` ; `context_ref` 为 `25357723a09218bae58626a9934309abcda34899` , claim 为 `claims/T-012` ,工作分支为 `agent/codex/T-012` 。
- 已读回 Issue `status/doing` 、assignee、dispatcher 发布的完整 CLAIM 与两个分支 SHA;接受 frontmatter 全部写路径。T-007 继续 waiting,本任务期间不恢复,当前无活跃写路径冲突。
2026-08-07 22:25:42 +08:00
### 2026-08-07 Gitea 映射
- 任务规格先通过 PR #42 合入默认分支,再创建唯一主 Issue #43 ;本次只回填双向映射,映射合入并读回前不领取任务。
2026-08-07 22:24:20 +08:00
### 2026-08-07 任务定义
- 项目负责人要求在 T-011 后创建并实施 T-012;当前只有 T-007 因外部真实设备条件处于 waiting,无开放 PR 或活跃 claim。
- 本任务把路线图的“对账器并发/10% 安全闸”和当前状态建议收敛为 PostgreSQL fencing、可证明 Path 所有权、只读孤儿报告、不可绕过的受控处置和低基数指标;不以未知 Path 的存在推断 Sense 有删除权。