diff --git a/contracts/source-config/v1/README.md b/contracts/source-config/v1/README.md index b40c82e..77e6cb4 100644 --- a/contracts/source-config/v1/README.md +++ b/contracts/source-config/v1/README.md @@ -15,11 +15,11 @@ ## 消费规则 1. 先按 JSON Schema 校验,再执行跨字段语义校验。 -2. `schema` 必须精确等于 `yovision.source-config/v1`;未知主版本不得降级猜测。 -3. `ruleSet.profileBinding` 必须与 `profile.id/width/height` 完全一致。 -4. `ruleSet.state != active` 时不得运行任何规则;`recalibration_required` 表示 Profile 规格变化后需重新标定。 -5. `areas` 与 `directionalLines` 的 `id` 在同一快照内必须全局唯一;多边形必须非退化,线段起终点不得相同。 -6. `effectiveAt` 不得早于 `publishedAt`。 +2. `schema_version` 必须精确等于 `yovision.source-config/v1`;未知主版本不得降级猜测。 +3. `rule_set.profile_binding` 必须与 `profile.id/width/height` 完全一致。 +4. `rule_set.state != active` 时不得运行任何规则;`recalibration_required` 表示 Profile 规格变化后需重新标定。 +5. `areas` 与 `directional_lines` 的 `id` 在同一快照内必须全局唯一;多边形必须非退化,线段起终点不得相同。 +6. `effective_at` 不得早于 `published_at`。 7. `integrity.value` 是移除顶层 `integrity` 后,对 RFC 8785 JCS 规范化 JSON 字节计算的 SHA-256 小写十六进制摘要。生产消费者应使用合规 JCS 实现;仓库样例只使用 JCS 简单类型子集。 `media.ref` 是 connector 解析的无凭据不透明引用,固定以 `media:` 开头。它不能包含 URI authority、用户名、密码、查询参数、fragment、Windows 盘符或文件系统路径。RTSP 凭据交换与机器身份不属于本契约。 diff --git a/contracts/source-config/v1/compatibility.md b/contracts/source-config/v1/compatibility.md index f7546c7..f434f14 100644 --- a/contracts/source-config/v1/compatibility.md +++ b/contracts/source-config/v1/compatibility.md @@ -3,15 +3,15 @@ ## 兼容规则 - v1 发布后只允许在预留的顶层 `extensions` 对象中增加命名空间化、非秘密的可选扩展。消费者必须忽略自己不认识的扩展命名空间,但仍须拒绝当前 Schema 或语义规则标记为非法的输入;发布扩展时应同步生产者/消费者测试。v1 核心对象保持封闭,不能通过新增核心字段规避新主版本。 -- 删除字段、把可选改为必填、收紧已发布取值范围,或改变字段类型、单位、坐标系、Profile 绑定、revision、状态及媒体引用语义,均为破坏性变化,必须发布新主版本目录和新的 `schema` 值。 +- 删除字段、把可选改为必填、收紧已发布取值范围,或改变字段类型、单位、坐标系、Profile 绑定、revision、状态及媒体引用语义,均为破坏性变化,必须发布新主版本目录和新的 `schema_version` 值。 - 未知主版本必须安全拒绝并保留最后一个已验证配置。不得把未知版本转换成 v1,也不得继续启用来自未知版本的规则。 - v1 的坐标始终是相对于 `profile.width × profile.height` 图像平面的 0–1 归一化坐标;原点在左上,x 向右、y 向下。该语义不得在 v1 内改变。 ## revision 与生效 -- `(configId, revision)` 唯一标识一个不可变快照;同一 `configId` 的新发布必须使用严格递增的 `revision`。 -- 消费者仅在 Schema、语义和完整性均通过后,按 `effectiveAt` 原子切换整个快照。重复收到同一 revision 应幂等处理;更小 revision 应拒绝为陈旧配置。 -- Profile ID、分辨率或编码变化时,生产者必须发布新 revision。已有几何尚未按新 Profile 校准时,必须设置 `ruleSet.state = recalibration_required`;消费者不得启用其中规则。 +- `(config_id, revision)` 唯一标识一个不可变快照;同一 `config_id` 的新发布必须使用严格递增的 `revision`。 +- 消费者仅在 Schema、语义和完整性均通过后,按 `effective_at` 原子切换整个快照。重复收到同一 revision 应幂等处理;更小 revision 应拒绝为陈旧配置。 +- Profile ID、分辨率或编码变化时,生产者必须发布新 revision。已有几何尚未按新 Profile 校准时,必须设置 `rule_set.state = recalibration_required`;消费者不得启用其中规则。 - 新 revision 校验失败或未到生效时间时,消费者保留上一份已验证且仍有效的 active revision。 ## 支持周期 @@ -22,7 +22,7 @@ ## 回退 1. 停止分发有问题的新主版本或新 revision。 -2. 重新发布上一主版本的最后一个已验证快照;若仍为同一 `configId`,必须使用该主版本下新的、更大 revision,不能覆盖历史 revision。 +2. 重新发布上一主版本的最后一个已验证快照;若仍为同一 `config_id`,必须使用该主版本下新的、更大 revision,不能覆盖历史 revision。 3. Brain 通过完整 Schema、语义和摘要校验后原子切回;切换前继续使用最后一个有效快照,或在没有有效快照时保持规则停用。 4. 记录失败版本和拒绝原因,但不得记录媒体凭据或完整客户配置。 diff --git a/contracts/source-config/v1/examples/invalid/coordinate-out-of-range.json b/contracts/source-config/v1/examples/invalid/coordinate-out-of-range.json index dce977f..108bf7b 100644 --- a/contracts/source-config/v1/examples/invalid/coordinate-out-of-range.json +++ b/contracts/source-config/v1/examples/invalid/coordinate-out-of-range.json @@ -1,13 +1,13 @@ { - "schema": "yovision.source-config/v1", - "configId": "school-east-entry-01", + "schema_version": "yovision.source-config/v1", + "config_id": "school-east-entry-01", "revision": 7, - "publishedAt": "2026-08-31T00:10:00Z", - "effectiveAt": "2026-08-31T00:15:00Z", + "published_at": "2026-08-31T00:10:00Z", + "effective_at": "2026-08-31T00:15:00Z", "site": {"id": "site-east"}, - "logicalDevice": {"id": "entry-camera-01"}, - "profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frameRate": 25}, + "logical_device": {"id": "entry-camera-01"}, + "profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frame_rate": 25}, "media": {"ref": "media:site-east/entry-01/main", "transport": "rtsp"}, - "ruleSet": {"version": "entry-rules-7", "state": "active", "profileBinding": {"profileId": "main-stream", "width": 1920, "height": 1080}, "areas": [{"id": "bad-area", "version": 1, "kind": "danger_area", "enabled": true, "points": [{"x": 0, "y": 0}, {"x": 1.2, "y": 0}, {"x": 0, "y": 1}]}], "directionalLines": []}, + "rule_set": {"version": "entry-rules-7", "state": "active", "profile_binding": {"profile_id": "main-stream", "width": 1920, "height": 1080}, "areas": [{"id": "bad-area", "version": 1, "kind": "danger_area", "enabled": true, "points": [{"x": 0, "y": 0}, {"x": 1.2, "y": 0}, {"x": 0, "y": 1}]}], "directional_lines": []}, "integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"} } diff --git a/contracts/source-config/v1/examples/invalid/credential-field.json b/contracts/source-config/v1/examples/invalid/credential-field.json index e58181a..ac0ce55 100644 --- a/contracts/source-config/v1/examples/invalid/credential-field.json +++ b/contracts/source-config/v1/examples/invalid/credential-field.json @@ -1,13 +1,13 @@ { - "schema": "yovision.source-config/v1", - "configId": "school-east-entry-01", + "schema_version": "yovision.source-config/v1", + "config_id": "school-east-entry-01", "revision": 7, - "publishedAt": "2026-08-31T00:10:00Z", - "effectiveAt": "2026-08-31T00:15:00Z", + "published_at": "2026-08-31T00:10:00Z", + "effective_at": "2026-08-31T00:15:00Z", "site": {"id": "site-east"}, - "logicalDevice": {"id": "entry-camera-01"}, - "profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frameRate": 25}, + "logical_device": {"id": "entry-camera-01"}, + "profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frame_rate": 25}, "media": {"ref": "media:site-east/entry-01/main", "transport": "rtsp", "password": null}, - "ruleSet": {"version": "entry-rules-7", "state": "active", "profileBinding": {"profileId": "main-stream", "width": 1920, "height": 1080}, "areas": [], "directionalLines": []}, + "rule_set": {"version": "entry-rules-7", "state": "active", "profile_binding": {"profile_id": "main-stream", "width": 1920, "height": 1080}, "areas": [], "directional_lines": []}, "integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"} } diff --git a/contracts/source-config/v1/examples/invalid/internal-path.json b/contracts/source-config/v1/examples/invalid/internal-path.json index 80adf98..5391989 100644 --- a/contracts/source-config/v1/examples/invalid/internal-path.json +++ b/contracts/source-config/v1/examples/invalid/internal-path.json @@ -1,13 +1,13 @@ { - "schema": "yovision.source-config/v1", - "configId": "school-east-entry-01", + "schema_version": "yovision.source-config/v1", + "config_id": "school-east-entry-01", "revision": 7, - "publishedAt": "2026-08-31T00:10:00Z", - "effectiveAt": "2026-08-31T00:15:00Z", + "published_at": "2026-08-31T00:10:00Z", + "effective_at": "2026-08-31T00:15:00Z", "site": {"id": "site-east"}, - "logicalDevice": {"id": "entry-camera-01"}, - "profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frameRate": 25}, + "logical_device": {"id": "entry-camera-01"}, + "profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frame_rate": 25}, "media": {"ref": "C:\\customers\\school-east\\camera-01", "transport": "rtsp"}, - "ruleSet": {"version": "entry-rules-7", "state": "active", "profileBinding": {"profileId": "main-stream", "width": 1920, "height": 1080}, "areas": [], "directionalLines": []}, + "rule_set": {"version": "entry-rules-7", "state": "active", "profile_binding": {"profile_id": "main-stream", "width": 1920, "height": 1080}, "areas": [], "directional_lines": []}, "integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"} } diff --git a/contracts/source-config/v1/examples/invalid/profile-binding-mismatch.json b/contracts/source-config/v1/examples/invalid/profile-binding-mismatch.json index 2189566..6b24511 100644 --- a/contracts/source-config/v1/examples/invalid/profile-binding-mismatch.json +++ b/contracts/source-config/v1/examples/invalid/profile-binding-mismatch.json @@ -1,13 +1,13 @@ { - "schema": "yovision.source-config/v1", - "configId": "school-east-entry-01", + "schema_version": "yovision.source-config/v1", + "config_id": "school-east-entry-01", "revision": 7, - "publishedAt": "2026-08-31T00:10:00Z", - "effectiveAt": "2026-08-31T00:15:00Z", + "published_at": "2026-08-31T00:10:00Z", + "effective_at": "2026-08-31T00:15:00Z", "site": {"id": "site-east"}, - "logicalDevice": {"id": "entry-camera-01"}, - "profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frameRate": 25}, + "logical_device": {"id": "entry-camera-01"}, + "profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frame_rate": 25}, "media": {"ref": "media:site-east/entry-01/main", "transport": "rtsp"}, - "ruleSet": {"version": "entry-rules-7", "state": "active", "profileBinding": {"profileId": "main-stream", "width": 1280, "height": 720}, "areas": [], "directionalLines": []}, + "rule_set": {"version": "entry-rules-7", "state": "active", "profile_binding": {"profile_id": "main-stream", "width": 1280, "height": 720}, "areas": [], "directional_lines": []}, "integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"} } diff --git a/contracts/source-config/v1/examples/invalid/query-token.json b/contracts/source-config/v1/examples/invalid/query-token.json index f8e204c..bf1ce64 100644 --- a/contracts/source-config/v1/examples/invalid/query-token.json +++ b/contracts/source-config/v1/examples/invalid/query-token.json @@ -1,13 +1,13 @@ { - "schema": "yovision.source-config/v1", - "configId": "school-east-entry-01", + "schema_version": "yovision.source-config/v1", + "config_id": "school-east-entry-01", "revision": 7, - "publishedAt": "2026-08-31T00:10:00Z", - "effectiveAt": "2026-08-31T00:15:00Z", + "published_at": "2026-08-31T00:10:00Z", + "effective_at": "2026-08-31T00:15:00Z", "site": {"id": "site-east"}, - "logicalDevice": {"id": "entry-camera-01"}, - "profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frameRate": 25}, + "logical_device": {"id": "entry-camera-01"}, + "profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frame_rate": 25}, "media": {"ref": "media:site-east/entry-01/main?token=", "transport": "rtsp"}, - "ruleSet": {"version": "entry-rules-7", "state": "active", "profileBinding": {"profileId": "main-stream", "width": 1920, "height": 1080}, "areas": [], "directionalLines": []}, + "rule_set": {"version": "entry-rules-7", "state": "active", "profile_binding": {"profile_id": "main-stream", "width": 1920, "height": 1080}, "areas": [], "directional_lines": []}, "integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"} } diff --git a/contracts/source-config/v1/examples/invalid/unknown-major-version.json b/contracts/source-config/v1/examples/invalid/unknown-major-version.json index 9b231fb..a8a2ddb 100644 --- a/contracts/source-config/v1/examples/invalid/unknown-major-version.json +++ b/contracts/source-config/v1/examples/invalid/unknown-major-version.json @@ -1,13 +1,13 @@ { - "schema": "yovision.source-config/v2", - "configId": "school-east-entry-01", + "schema_version": "yovision.source-config/v2", + "config_id": "school-east-entry-01", "revision": 7, - "publishedAt": "2026-08-31T00:10:00Z", - "effectiveAt": "2026-08-31T00:15:00Z", + "published_at": "2026-08-31T00:10:00Z", + "effective_at": "2026-08-31T00:15:00Z", "site": {"id": "site-east"}, - "logicalDevice": {"id": "entry-camera-01"}, - "profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frameRate": 25}, + "logical_device": {"id": "entry-camera-01"}, + "profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frame_rate": 25}, "media": {"ref": "media:site-east/entry-01/main", "transport": "rtsp"}, - "ruleSet": {"version": "entry-rules-7", "state": "active", "profileBinding": {"profileId": "main-stream", "width": 1920, "height": 1080}, "areas": [], "directionalLines": []}, + "rule_set": {"version": "entry-rules-7", "state": "active", "profile_binding": {"profile_id": "main-stream", "width": 1920, "height": 1080}, "areas": [], "directional_lines": []}, "integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"} } diff --git a/contracts/source-config/v1/examples/valid/active.json b/contracts/source-config/v1/examples/valid/active.json index 0f2f408..deb2bc7 100644 --- a/contracts/source-config/v1/examples/valid/active.json +++ b/contracts/source-config/v1/examples/valid/active.json @@ -1,13 +1,13 @@ { - "schema": "yovision.source-config/v1", - "configId": "school-east-entry-01", + "schema_version": "yovision.source-config/v1", + "config_id": "school-east-entry-01", "revision": 7, - "publishedAt": "2026-08-31T00:10:00Z", - "effectiveAt": "2026-08-31T00:15:00Z", + "published_at": "2026-08-31T00:10:00Z", + "effective_at": "2026-08-31T00:15:00Z", "site": { "id": "site-east" }, - "logicalDevice": { + "logical_device": { "id": "entry-camera-01" }, "profile": { @@ -15,17 +15,17 @@ "width": 1920, "height": 1080, "encoding": "H264", - "frameRate": 25 + "frame_rate": 25 }, "media": { "ref": "media:site-east/entry-01/main", "transport": "rtsp" }, - "ruleSet": { + "rule_set": { "version": "entry-rules-7", "state": "active", - "profileBinding": { - "profileId": "main-stream", + "profile_binding": { + "profile_id": "main-stream", "width": 1920, "height": 1080 }, @@ -43,7 +43,7 @@ ] } ], - "directionalLines": [ + "directional_lines": [ { "id": "entry-line", "version": 2, @@ -51,12 +51,12 @@ "enabled": true, "start": {"x": 0.2, "y": 0.5}, "end": {"x": 0.8, "y": 0.5}, - "triggerDirection": "left_to_right" + "trigger_direction": "left_to_right" } ] }, "integrity": { "algorithm": "sha256", - "value": "b35a4f345162b059d6912cca6340a6714826b7921662f4f28a91ef29c5476cb8" + "value": "3336fe595bf1401b1024ac0c95c31e1655228485465a4527900fcea2c713acfe" } } diff --git a/contracts/source-config/v1/examples/valid/recalibration-required.json b/contracts/source-config/v1/examples/valid/recalibration-required.json index 691b661..1c67fbc 100644 --- a/contracts/source-config/v1/examples/valid/recalibration-required.json +++ b/contracts/source-config/v1/examples/valid/recalibration-required.json @@ -1,13 +1,13 @@ { - "schema": "yovision.source-config/v1", - "configId": "school-east-entry-01", + "schema_version": "yovision.source-config/v1", + "config_id": "school-east-entry-01", "revision": 8, - "publishedAt": "2026-08-31T01:00:00Z", - "effectiveAt": "2026-08-31T01:00:00Z", + "published_at": "2026-08-31T01:00:00Z", + "effective_at": "2026-08-31T01:00:00Z", "site": { "id": "site-east" }, - "logicalDevice": { + "logical_device": { "id": "entry-camera-01" }, "profile": { @@ -15,25 +15,25 @@ "width": 1280, "height": 720, "encoding": "H265", - "frameRate": 20 + "frame_rate": 20 }, "media": { "ref": "media:site-east/entry-01/main-v2", "transport": "rtsp" }, - "ruleSet": { + "rule_set": { "version": "entry-rules-8", "state": "recalibration_required", - "profileBinding": { - "profileId": "main-stream-v2", + "profile_binding": { + "profile_id": "main-stream-v2", "width": 1280, "height": 720 }, "areas": [], - "directionalLines": [] + "directional_lines": [] }, "integrity": { "algorithm": "sha256", - "value": "04408f00740558c70d9d1e3f9f5efbf03e6f22ccc0ba4ec6b6c8688df7b568df" + "value": "a53e6df8bab5c9a4e3f2dae2e82959939db09d34529af9ae65d66f322be833ba" } } diff --git a/contracts/source-config/v1/mapper-fields.md b/contracts/source-config/v1/mapper-fields.md index 73cdfea..0a985a3 100644 --- a/contracts/source-config/v1/mapper-fields.md +++ b/contracts/source-config/v1/mapper-fields.md @@ -4,32 +4,32 @@ mapper 必须创建新的契约 DTO,不得直接序列化 Sense GORM 实体, | 契约字段 | Sense 生产来源/规则 | Brain 消费目标/规则 | |---|---|---| -| `schema` | 常量 `yovision.source-config/v1` | 在任何映射前精确校验;未知主版本拒绝 | -| `configId` | 新的稳定配置聚合 ID;不是数据库行 ID 语义 | 作为配置流逻辑 ID,不解释为 Brain 内部对象 ID | -| `revision` | 聚合配置变更时严格递增;不可复用 | 与 `configId` 共同做幂等、顺序和陈旧检查 | -| `publishedAt` / `effectiveAt` | 发布时写 UTC RFC 3339;生效不得早于发布 | 完整校验后按生效时间原子切换 | +| `schema_version` | 常量 `yovision.source-config/v1` | 在任何映射前精确校验;未知主版本拒绝 | +| `config_id` | 新的稳定配置聚合 ID;不是数据库行 ID 语义 | 作为配置流逻辑 ID,不解释为 Brain 内部对象 ID | +| `revision` | 聚合配置变更时严格递增;不可复用 | 与 `config_id` 共同做幂等、顺序和陈旧检查 | +| `published_at` / `effective_at` | 发布时写 UTC RFC 3339;生效不得早于发布 | 完整校验后按生效时间原子切换 | | `site.id` | 对外稳定站点引用;不得映射客户名或数据库主键语义 | 仅作租户隔离后的逻辑关联;v1 不提供用户身份 | -| `logicalDevice.id` | `area.Definition.DeviceID` / `media.Route.DeviceID` 经稳定外部 ID mapper | 映射到 `BrainInputConfig.logical_device_id` | +| `logical_device.id` | `area.Definition.DeviceID` / `media.Route.DeviceID` 经稳定外部 ID mapper | 映射到 `BrainInputConfig.logical_device_id` | | `profile.id` | `area.Definition.ProfileToken` 与 `media.Route.ProfileToken` 经稳定 Profile ID mapper | 映射到 `BrainInputConfig.profile.profile_id` | | `profile.width/height/encoding` | `area.Definition.ProfileWidth/ProfileHeight/ProfileEncoding`;必须与当前媒体 Profile 一致 | 映射到规则 `RuleSet` 的 Profile 绑定;不一致拒绝 | -| `profile.frameRate` | Sense 已验证 Profile 的帧率快照 | 映射到 `BrainInputConfig.profile.fps` | +| `profile.frame_rate` | Sense 已验证 Profile 的帧率快照 | 映射到 `BrainInputConfig.profile.fps` | | `media.ref` | 由 `media.Route.ID/Path` 生成 `media:`;禁止读取或拼入 `admissionProfile.StreamURI` 及凭据 | 交给后续 connector 解析;不得当作 RTSP URL 或本地路径 | | `media.transport` | 当前固定 `rtsp`,仅描述媒体传输类别 | 选择后续 connector/decode adapter;不含认证信息 | -| `ruleSet.version` | 由一组 `area.Version` 聚合成稳定规则集版本 | 映射到 Brain `RuleSet.version` | -| `ruleSet.state` | `NeedsRecalibration=true` → `recalibration_required`;整体禁用 → `disabled`;否则 `active` | 只有 `active` 可构建并启用规则引擎 | -| `ruleSet.profileBinding` | 与本快照 `profile.id/width/height` 同源复制并交叉校验 | 必须精确等于 `profile`;之后才接受归一化几何 | -| `ruleSet.areas[].id/version` | `area.Version.DefinitionID/Version` 经稳定规则 ID mapper | 映射到 `AreaRule.rule_id`;version 用于可追溯性 | -| `ruleSet.areas[].kind` | Sense `polygon` 映射为 `danger_area` | 只映射到 Brain 危险区域规则,不透传 Sense 枚举 | -| `ruleSet.areas[].points` | `area.Version.GeometryJSON` 中 `{x,y}`;保持 0–1 | 映射到 Brain `Point(x,y)`;至少三点且非退化 | -| `ruleSet.directionalLines[].id/version` | `area.Version.DefinitionID/Version` 经稳定规则 ID mapper | 映射到 `DirectionalLineRule.rule_id` | -| `ruleSet.directionalLines[].start/end` | `direction_line` 几何的两个归一化点 | 映射到 Brain `Point`;相同点拒绝 | -| `ruleSet.directionalLines[].triggerDirection` | Sense `forward/reverse` 必须由 mapper 根据已确认的起终点方向转换为 `left_to_right/right_to_left` | 映射到 `DirectionalLineRule.trigger_direction`;不得直接猜测枚举 | +| `rule_set.version` | 由一组 `area.Version` 聚合成稳定规则集版本 | 映射到 Brain `RuleSet.version` | +| `rule_set.state` | `NeedsRecalibration=true` → `recalibration_required`;整体禁用 → `disabled`;否则 `active` | 只有 `active` 可构建并启用规则引擎 | +| `rule_set.profile_binding` | 与本快照 `profile.id/width/height` 同源复制并交叉校验 | 必须精确等于 `profile`;之后才接受归一化几何 | +| `rule_set.areas[].id/version` | `area.Version.DefinitionID/Version` 经稳定规则 ID mapper | 映射到 `AreaRule.rule_id`;version 用于可追溯性 | +| `rule_set.areas[].kind` | Sense `polygon` 映射为 `danger_area` | 只映射到 Brain 危险区域规则,不透传 Sense 枚举 | +| `rule_set.areas[].points` | `area.Version.GeometryJSON` 中 `{x,y}`;保持 0–1 | 映射到 Brain `Point(x,y)`;至少三点且非退化 | +| `rule_set.directional_lines[].id/version` | `area.Version.DefinitionID/Version` 经稳定规则 ID mapper | 映射到 `DirectionalLineRule.rule_id` | +| `rule_set.directional_lines[].start/end` | `direction_line` 几何的两个归一化点 | 映射到 Brain `Point`;相同点拒绝 | +| `rule_set.directional_lines[].trigger_direction` | Sense `forward/reverse` 必须由 mapper 根据已确认的起终点方向转换为 `left_to_right/right_to_left` | 映射到 `DirectionalLineRule.trigger_direction`;不得直接猜测枚举 | | `integrity` | 对移除 `integrity` 的 JCS 快照计算 SHA-256 | 映射前重算并常量时间比较;失败保留上一有效 revision | ## 测试责任 - Sense 生产者契约测试:从设备、媒体 Route、Profile 与区域版本 fixture 生成快照;断言字段映射、revision 递增、Profile 变化触发新 revision/待重校准、无秘密媒体引用、Schema/语义/摘要通过。 -- Brain 消费者契约测试:加载本目录有效与无效样例;断言版本拒绝、幂等/陈旧处理、Profile 绑定、坐标、规则 ID、状态门禁和摘要;再映射为 Brain 内部配置,证明共享 `schema` 不等于 `brain.internal.input/v1`。 +- Brain 消费者契约测试:加载本目录有效与无效样例;断言版本拒绝、幂等/陈旧处理、Profile 绑定、坐标、规则 ID、状态门禁和摘要;再映射为 Brain 内部配置,证明共享 `schema_version` 不等于 `brain.internal.input/v1`。 - 协调契约测试(本工单):校验所有样例、秘密字段/URL/本地路径拒绝、跨字段语义和摘要。产品 adapter 测试在后续 connector 工单实施。 Sense 与 Brain 各自可增加内部字段,但不得将数据库主键、用户表、JWT、Cookie、摄像头凭据、客户内部路径或内部模型直接扩展进本契约。 diff --git a/contracts/source-config/v1/source-config.schema.json b/contracts/source-config/v1/source-config.schema.json index cb1e2fe..e28a060 100644 --- a/contracts/source-config/v1/source-config.schema.json +++ b/contracts/source-config/v1/source-config.schema.json @@ -6,34 +6,34 @@ "type": "object", "additionalProperties": false, "required": [ - "schema", - "configId", + "schema_version", + "config_id", "revision", - "publishedAt", - "effectiveAt", + "published_at", + "effective_at", "site", - "logicalDevice", + "logical_device", "profile", "media", - "ruleSet", + "rule_set", "integrity" ], "properties": { - "schema": { + "schema_version": { "const": "yovision.source-config/v1" }, - "configId": { - "$ref": "#/$defs/stableId" + "config_id": { + "$ref": "#/$defs/stable_id" }, "revision": { "type": "integer", "minimum": 1 }, - "publishedAt": { + "published_at": { "type": "string", "format": "date-time" }, - "effectiveAt": { + "effective_at": { "type": "string", "format": "date-time" }, @@ -43,17 +43,17 @@ "required": ["id"], "properties": { "id": { - "$ref": "#/$defs/stableId" + "$ref": "#/$defs/stable_id" } } }, - "logicalDevice": { + "logical_device": { "type": "object", "additionalProperties": false, "required": ["id"], "properties": { "id": { - "$ref": "#/$defs/stableId" + "$ref": "#/$defs/stable_id" } } }, @@ -75,37 +75,37 @@ } } }, - "ruleSet": { + "rule_set": { "type": "object", "additionalProperties": false, "required": [ "version", "state", - "profileBinding", + "profile_binding", "areas", - "directionalLines" + "directional_lines" ], "properties": { "version": { - "$ref": "#/$defs/stableId" + "$ref": "#/$defs/stable_id" }, "state": { "enum": ["active", "disabled", "recalibration_required"] }, - "profileBinding": { - "$ref": "#/$defs/profileBinding" + "profile_binding": { + "$ref": "#/$defs/profile_binding" }, "areas": { "type": "array", "items": { - "$ref": "#/$defs/areaRule" + "$ref": "#/$defs/area_rule" }, "maxItems": 1024 }, - "directionalLines": { + "directional_lines": { "type": "array", "items": { - "$ref": "#/$defs/directionalLineRule" + "$ref": "#/$defs/directional_line_rule" }, "maxItems": 1024 } @@ -137,55 +137,55 @@ } }, "$defs": { - "stableId": { + "stable_id": { "type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[A-Za-z0-9][A-Za-z0-9._~-]*$" }, - "positiveInteger": { + "positive_integer": { "type": "integer", "minimum": 1 }, - "positiveNumber": { + "positive_number": { "type": "number", "exclusiveMinimum": 0 }, "profile": { "type": "object", "additionalProperties": false, - "required": ["id", "width", "height", "encoding", "frameRate"], + "required": ["id", "width", "height", "encoding", "frame_rate"], "properties": { "id": { - "$ref": "#/$defs/stableId" + "$ref": "#/$defs/stable_id" }, "width": { - "$ref": "#/$defs/positiveInteger" + "$ref": "#/$defs/positive_integer" }, "height": { - "$ref": "#/$defs/positiveInteger" + "$ref": "#/$defs/positive_integer" }, "encoding": { "enum": ["H264", "H265", "MJPEG"] }, - "frameRate": { - "$ref": "#/$defs/positiveNumber" + "frame_rate": { + "$ref": "#/$defs/positive_number" } } }, - "profileBinding": { + "profile_binding": { "type": "object", "additionalProperties": false, - "required": ["profileId", "width", "height"], + "required": ["profile_id", "width", "height"], "properties": { - "profileId": { - "$ref": "#/$defs/stableId" + "profile_id": { + "$ref": "#/$defs/stable_id" }, "width": { - "$ref": "#/$defs/positiveInteger" + "$ref": "#/$defs/positive_integer" }, "height": { - "$ref": "#/$defs/positiveInteger" + "$ref": "#/$defs/positive_integer" } } }, @@ -206,16 +206,16 @@ } } }, - "areaRule": { + "area_rule": { "type": "object", "additionalProperties": false, "required": ["id", "version", "kind", "enabled", "points"], "properties": { "id": { - "$ref": "#/$defs/stableId" + "$ref": "#/$defs/stable_id" }, "version": { - "$ref": "#/$defs/positiveInteger" + "$ref": "#/$defs/positive_integer" }, "kind": { "const": "danger_area" @@ -233,7 +233,7 @@ } } }, - "directionalLineRule": { + "directional_line_rule": { "type": "object", "additionalProperties": false, "required": [ @@ -243,14 +243,14 @@ "enabled", "start", "end", - "triggerDirection" + "trigger_direction" ], "properties": { "id": { - "$ref": "#/$defs/stableId" + "$ref": "#/$defs/stable_id" }, "version": { - "$ref": "#/$defs/positiveInteger" + "$ref": "#/$defs/positive_integer" }, "kind": { "const": "directional_line" @@ -264,7 +264,7 @@ "end": { "$ref": "#/$defs/point" }, - "triggerDirection": { + "trigger_direction": { "enum": ["left_to_right", "right_to_left"] } } diff --git a/contracts/tests/source-config-v1/test_source_config_v1.py b/contracts/tests/source-config-v1/test_source_config_v1.py index 4adfecd..cbc8a15 100644 --- a/contracts/tests/source-config-v1/test_source_config_v1.py +++ b/contracts/tests/source-config-v1/test_source_config_v1.py @@ -67,7 +67,7 @@ def polygon_area(points: list[dict[str, float]]) -> float: def validate_payload(payload: dict[str, Any]) -> None: - if payload.get("schema") != "yovision.source-config/v1": + if payload.get("schema_version") != "yovision.source-config/v1": raise ValueError("unknown schema major version") reject_secrets(payload) @@ -86,21 +86,21 @@ def validate_payload(payload: dict[str, Any]) -> None: raise ValueError(f"schema validation failed at {location}: {first.message}") profile = payload["profile"] - binding = payload["ruleSet"]["profileBinding"] - if (binding["profileId"], binding["width"], binding["height"]) != ( + binding = payload["rule_set"]["profile_binding"] + if (binding["profile_id"], binding["width"], binding["height"]) != ( profile["id"], profile["width"], profile["height"], ): raise ValueError("profile binding does not match the media profile") - published_at = datetime.fromisoformat(payload["publishedAt"].replace("Z", "+00:00")) - effective_at = datetime.fromisoformat(payload["effectiveAt"].replace("Z", "+00:00")) + published_at = datetime.fromisoformat(payload["published_at"].replace("Z", "+00:00")) + effective_at = datetime.fromisoformat(payload["effective_at"].replace("Z", "+00:00")) if effective_at < published_at: - raise ValueError("effectiveAt precedes publishedAt") + raise ValueError("effective_at precedes published_at") - rule_set = payload["ruleSet"] - rules = [*rule_set["areas"], *rule_set["directionalLines"]] + rule_set = payload["rule_set"] + rules = [*rule_set["areas"], *rule_set["directional_lines"]] identifiers = [rule["id"] for rule in rules] if len(identifiers) != len(set(identifiers)): raise ValueError("rule ids must be unique across the rule set") @@ -110,7 +110,7 @@ def validate_payload(payload: dict[str, Any]) -> None: for area in rule_set["areas"]: if polygon_area(area["points"]) <= 1e-12: raise ValueError(f"area {area['id']} is a degenerate polygon") - for line in rule_set["directionalLines"]: + for line in rule_set["directional_lines"]: if line["start"] == line["end"]: raise ValueError(f"directional line {line['id']} has identical endpoints") @@ -121,8 +121,8 @@ def validate_payload(payload: dict[str, Any]) -> None: def validate_transition(previous: dict[str, Any], current: dict[str, Any]) -> None: validate_payload(previous) validate_payload(current) - if previous["configId"] != current["configId"]: - raise ValueError("configId cannot change within one revision stream") + if previous["config_id"] != current["config_id"]: + raise ValueError("config_id cannot change within one revision stream") if current["revision"] <= previous["revision"]: raise ValueError("revision must increase strictly") @@ -134,16 +134,16 @@ def validate_transition(previous: dict[str, Any], current: dict[str, Any]) -> No ) previous_rule_versions = sorted( (rule["id"], rule["version"]) - for rule in [*previous["ruleSet"]["areas"], *previous["ruleSet"]["directionalLines"]] + for rule in [*previous["rule_set"]["areas"], *previous["rule_set"]["directional_lines"]] ) current_rule_versions = sorted( (rule["id"], rule["version"]) - for rule in [*current["ruleSet"]["areas"], *current["ruleSet"]["directionalLines"]] + for rule in [*current["rule_set"]["areas"], *current["rule_set"]["directional_lines"]] ) if ( profile_changed and previous_rule_versions == current_rule_versions - and current["ruleSet"]["state"] != "recalibration_required" + and current["rule_set"]["state"] != "recalibration_required" ): raise ValueError("profile changed without rule recalibration state or new rule versions") @@ -186,13 +186,13 @@ class SourceConfigV1ContractTests(unittest.TestCase): def test_profile_revision_and_recalibration_semantics_are_safe(self) -> None: payload = load_json(VALID_ROOT / "active.json") - payload["ruleSet"]["profileBinding"]["width"] = 1280 + payload["rule_set"]["profile_binding"]["width"] = 1280 set_integrity(payload) with self.assertRaisesRegex(ValueError, "profile binding"): validate_payload(payload) payload = load_json(VALID_ROOT / "active.json") - payload["ruleSet"]["state"] = "recalibration_required" + payload["rule_set"]["state"] = "recalibration_required" set_integrity(payload) with self.assertRaisesRegex(ValueError, "must not remain enabled"): validate_payload(payload) @@ -207,8 +207,8 @@ class SourceConfigV1ContractTests(unittest.TestCase): current["revision"] += 1 current["profile"].update({"id": "main-stream-v2", "width": 1280, "height": 720}) - current["ruleSet"]["profileBinding"].update( - {"profileId": "main-stream-v2", "width": 1280, "height": 720} + current["rule_set"]["profile_binding"].update( + {"profile_id": "main-stream-v2", "width": 1280, "height": 720} ) set_integrity(current) with self.assertRaisesRegex(ValueError, "without rule recalibration"): @@ -218,7 +218,7 @@ class SourceConfigV1ContractTests(unittest.TestCase): def test_rule_geometry_and_global_ids_are_semantically_validated(self) -> None: payload = load_json(VALID_ROOT / "active.json") - payload["ruleSet"]["areas"][0]["points"] = [ + payload["rule_set"]["areas"][0]["points"] = [ {"x": 0, "y": 0}, {"x": 0.5, "y": 0.5}, {"x": 1, "y": 1}, @@ -228,14 +228,14 @@ class SourceConfigV1ContractTests(unittest.TestCase): validate_payload(payload) payload = load_json(VALID_ROOT / "active.json") - payload["ruleSet"]["directionalLines"][0]["id"] = payload["ruleSet"]["areas"][0]["id"] + payload["rule_set"]["directional_lines"][0]["id"] = payload["rule_set"]["areas"][0]["id"] set_integrity(payload) with self.assertRaisesRegex(ValueError, "ids must be unique"): validate_payload(payload) def test_effective_time_cannot_precede_publication(self) -> None: payload = load_json(VALID_ROOT / "active.json") - payload["effectiveAt"] = "2026-08-30T23:59:59Z" + payload["effective_at"] = "2026-08-30T23:59:59Z" set_integrity(payload) with self.assertRaisesRegex(ValueError, "precedes"): validate_payload(payload)