diff --git a/contracts/runtime-status/v1/README.md b/contracts/runtime-status/v1/README.md new file mode 100644 index 0000000..e44af71 --- /dev/null +++ b/contracts/runtime-status/v1/README.md @@ -0,0 +1,42 @@ +# Brain → Sense 运行与健康状态契约 v1 + +本目录是 Brain 运行状态到 Sense 运维投影的版本化事实源。Brain 只发布脱敏状态事实;Sense 不读取 Brain 的缓存、数据库或内部运行对象,也不能借此契约执行远程命令。 + +## 消息与时间语义 + +- `schema_version` 固定为 `runtime-status/v1`。生产者必须先通过 `runtime-status.schema.json` 再发布。 +- `status_id` 是消息幂等键;`sequence` 在单个 `brain_instance_ref` 内单调递增。重复消息可忽略;小于当前已保存 sequence 的消息不得覆盖投影。 +- `observed_at` 是 Brain 完成该次观测的 UTC RFC 3339 时间,不是 Sense 的接收时间。允许最大 30 秒未来时钟偏差;超过时拒绝该消息,并保留最后已知投影。 +- Brain 的推荐发布周期是 30 秒。Sense 以 `evaluation_time - observed_at > 90 秒` 推导 `stale`;恰好 90 秒仍为 fresh。`stale` 和 `offline` 都是 Sense 的传输/时间投影,不是 Brain 写入的运行状态。 +- 未收到任何有效状态时显示 `not_received`;传输断开但最后状态未过期时显示 `offline_fresh`;传输断开或无新消息且超过 90 秒时显示 `offline_stale` / `stale`,同时保留最后已知状态及其观测时间。 + +## 状态机 + +Brain 报告的 `runtime.state` 和每个输入的 `state` 使用同一枚举: + +| 状态 | 含义 | 允许的下一状态 | +|---|---|---| +| `unconfigured` | 尚无可运行配置 | `starting`, `stopped` | +| `starting` | 已接受启动,资源准备中 | `running`, `degraded`, `failed`, `stopped` | +| `running` | 正常提供推理 | `degraded`, `failed`, `stopped` | +| `degraded` | 仍提供有限服务 | `running`, `failed`, `stopped` | +| `failed` | 无法继续提供服务 | `starting`, `stopped` | +| `stopped` | 已有序停止 | `starting`, `unconfigured` | + +首次有效消息可为任一状态;Sense 只校验同实例连续消息的迁移。`stale`、`offline_*` 不参与 Brain 状态迁移。恢复连接后,只有 schema、时间、sequence 和状态迁移均有效的新消息才能更新投影。 + +## 配置 revision + +`configuration.applied_revision` 只表示 Brain 已实际应用的配置。Sense 将它与自己投递成功的期望 revision 比较:相等为 synchronized,不相等为 mismatch。Sense 的期望 revision 不进入本消息,避免产生第二事实源。`not_configured` 时 revision 必须为 null;`applied` 时必须非空;`rejected` 必须带错误码。 + +## 兼容与回退 + +- v1 字段语义冻结,未知字段被拒绝。新增可选字段或错误码前必须更新本契约及双方测试;改变字段语义或删除字段发布新主版本。 +- 消费者必须按 `schema_version` 先分派到对应版本验证器。未知主版本停止摄取并记录 `UNSUPPORTED_SCHEMA_VERSION`,不得清空或覆盖最后已知投影。 +- 回退时 Sense 停止摄取新版本,继续使用上一冻结版本的 adapter 和最后已知投影。回退不触发 Brain 重启或运行态修改。 + +## 安全边界 + +只允许 Schema 列出的字段。逻辑引用不允许 `/` 或 `\\`,因此不能携带绝对路径。消息不得包含凭据/token、堆栈、内部路径、用户会话、客户视频/图像、人脸信息或业务 Alert。结构化错误只传稳定错误码,不传自由文本错误详情。 + +错误码、映射责任和可复制验证分别见 `error-codes.md`、`mapping.md` 与 `../../tests/runtime-status-v1/README.md`。 diff --git a/contracts/runtime-status/v1/error-codes.md b/contracts/runtime-status/v1/error-codes.md new file mode 100644 index 0000000..c468ba1 --- /dev/null +++ b/contracts/runtime-status/v1/error-codes.md @@ -0,0 +1,16 @@ +# v1 稳定错误码 + +生产者可以发布以下稳定错误码。消费者遇到符合格式但尚未认识的 v1 错误码时显示“未识别的远端错误”,保留原始代码用于排障,不把它转换成业务 Alert。 + +| 错误码 | 责任域 | 含义 | +|---|---|---| +| `CONFIG_INVALID` | 配置 | 配置结构或值无效 | +| `CONFIG_REVISION_UNAVAILABLE` | 配置 | 指定 revision 无法取得 | +| `INPUT_UNREACHABLE` | 输入 | 逻辑输入暂时不可达 | +| `INPUT_DECODE_FAILED` | 输入 | 输入解码失败 | +| `MODEL_LOAD_FAILED` | 模型 | 模型载入失败 | +| `INFERENCE_FAILED` | 推理 | 推理管线失败 | +| `RESOURCE_PRESSURE` | 运行 | 资源压力导致降级 | +| `INTERNAL_COMPONENT_FAILED` | 运行 | 内部组件失败;不随消息暴露组件路径或堆栈 | + +`UNSUPPORTED_SCHEMA_VERSION`、`FUTURE_OBSERVATION`、`OUT_OF_ORDER_STATUS` 与 `INVALID_STATUS_TRANSITION` 是 Sense adapter 的本地摄取错误,不由 Brain 发布。 diff --git a/contracts/runtime-status/v1/examples/invalid/business-alert.json b/contracts/runtime-status/v1/examples/invalid/business-alert.json new file mode 100644 index 0000000..24cbe8c --- /dev/null +++ b/contracts/runtime-status/v1/examples/invalid/business-alert.json @@ -0,0 +1,13 @@ +{ + "schema_version": "runtime-status/v1", + "status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d205", + "brain_instance_ref": "brain-east-01", + "sequence": 1, + "observed_at": "2026-08-31T00:00:00Z", + "runtime": { "state": "running", "version": "1.0.0", "started_at": null }, + "model": { "model_ref": "people-detection", "version": "1.0.0" }, + "configuration": { "apply_state": "applied", "applied_revision": "rev-1", "error_code": null }, + "health": { "overall": "healthy", "error_codes": [], "metrics": { "load_percent": 1, "queue_depth": 0, "latency_ms": 1 } }, + "inputs": [], + "alert": { "kind": "intrusion" } +} diff --git a/contracts/runtime-status/v1/examples/invalid/credential.json b/contracts/runtime-status/v1/examples/invalid/credential.json new file mode 100644 index 0000000..5838210 --- /dev/null +++ b/contracts/runtime-status/v1/examples/invalid/credential.json @@ -0,0 +1,13 @@ +{ + "schema_version": "runtime-status/v1", + "status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d202", + "brain_instance_ref": "brain-east-01", + "sequence": 1, + "observed_at": "2026-08-31T00:00:00Z", + "runtime": { "state": "running", "version": "1.0.0", "started_at": null }, + "model": { "model_ref": "people-detection", "version": "1.0.0" }, + "configuration": { "apply_state": "applied", "applied_revision": "rev-1", "error_code": null }, + "health": { "overall": "healthy", "error_codes": [], "metrics": { "load_percent": 1, "queue_depth": 0, "latency_ms": 1 } }, + "inputs": [], + "access_token": "forbidden-example" +} diff --git a/contracts/runtime-status/v1/examples/invalid/internal-path.json b/contracts/runtime-status/v1/examples/invalid/internal-path.json new file mode 100644 index 0000000..3f4a8a6 --- /dev/null +++ b/contracts/runtime-status/v1/examples/invalid/internal-path.json @@ -0,0 +1,12 @@ +{ + "schema_version": "runtime-status/v1", + "status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d203", + "brain_instance_ref": "brain-east-01", + "sequence": 1, + "observed_at": "2026-08-31T00:00:00Z", + "runtime": { "state": "running", "version": "1.0.0", "started_at": null }, + "model": { "model_ref": "C:\\models\\private.pt", "version": "1.0.0" }, + "configuration": { "apply_state": "applied", "applied_revision": "rev-1", "error_code": null }, + "health": { "overall": "healthy", "error_codes": [], "metrics": { "load_percent": 1, "queue_depth": 0, "latency_ms": 1 } }, + "inputs": [] +} diff --git a/contracts/runtime-status/v1/examples/invalid/unknown-major.json b/contracts/runtime-status/v1/examples/invalid/unknown-major.json new file mode 100644 index 0000000..5a4be6d --- /dev/null +++ b/contracts/runtime-status/v1/examples/invalid/unknown-major.json @@ -0,0 +1,12 @@ +{ + "schema_version": "runtime-status/v2", + "status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d201", + "brain_instance_ref": "brain-east-01", + "sequence": 1, + "observed_at": "2026-08-31T00:00:00Z", + "runtime": { "state": "running", "version": "1.0.0", "started_at": null }, + "model": { "model_ref": "people-detection", "version": "1.0.0" }, + "configuration": { "apply_state": "applied", "applied_revision": "rev-1", "error_code": null }, + "health": { "overall": "healthy", "error_codes": [], "metrics": { "load_percent": 1, "queue_depth": 0, "latency_ms": 1 } }, + "inputs": [] +} diff --git a/contracts/runtime-status/v1/examples/invalid/user-session.json b/contracts/runtime-status/v1/examples/invalid/user-session.json new file mode 100644 index 0000000..68b7b86 --- /dev/null +++ b/contracts/runtime-status/v1/examples/invalid/user-session.json @@ -0,0 +1,13 @@ +{ + "schema_version": "runtime-status/v1", + "status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d204", + "brain_instance_ref": "brain-east-01", + "sequence": 1, + "observed_at": "2026-08-31T00:00:00Z", + "runtime": { "state": "running", "version": "1.0.0", "started_at": null }, + "model": { "model_ref": "people-detection", "version": "1.0.0" }, + "configuration": { "apply_state": "applied", "applied_revision": "rev-1", "error_code": null }, + "health": { "overall": "healthy", "error_codes": [], "metrics": { "load_percent": 1, "queue_depth": 0, "latency_ms": 1 } }, + "inputs": [], + "user_session": { "user": "forbidden" } +} diff --git a/contracts/runtime-status/v1/examples/valid/config-mismatch.json b/contracts/runtime-status/v1/examples/valid/config-mismatch.json new file mode 100644 index 0000000..8b488a2 --- /dev/null +++ b/contracts/runtime-status/v1/examples/valid/config-mismatch.json @@ -0,0 +1,16 @@ +{ + "schema_version": "runtime-status/v1", + "status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d103", + "brain_instance_ref": "brain-east-01", + "sequence": 43, + "observed_at": "2026-08-31T00:01:00Z", + "runtime": { "state": "running", "version": "1.0.0", "started_at": "2026-08-30T23:55:00Z" }, + "model": { "model_ref": "people-detection", "version": "2026.08.1" }, + "configuration": { "apply_state": "applied", "applied_revision": "sense-rev-20", "error_code": null }, + "health": { + "overall": "healthy", + "error_codes": [], + "metrics": { "load_percent": 40.0, "queue_depth": 0, "latency_ms": 22.0 } + }, + "inputs": [] +} diff --git a/contracts/runtime-status/v1/examples/valid/degraded.json b/contracts/runtime-status/v1/examples/valid/degraded.json new file mode 100644 index 0000000..805baee --- /dev/null +++ b/contracts/runtime-status/v1/examples/valid/degraded.json @@ -0,0 +1,23 @@ +{ + "schema_version": "runtime-status/v1", + "status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d102", + "brain_instance_ref": "brain-east-01", + "sequence": 42, + "observed_at": "2026-08-31T00:00:30Z", + "runtime": { "state": "degraded", "version": "1.0.0", "started_at": "2026-08-30T23:55:00Z" }, + "model": { "model_ref": "people-detection", "version": "2026.08.1" }, + "configuration": { "apply_state": "applied", "applied_revision": "sense-rev-21", "error_code": null }, + "health": { + "overall": "degraded", + "error_codes": ["RESOURCE_PRESSURE"], + "metrics": { "load_percent": 91.5, "queue_depth": 7, "latency_ms": 115.0 } + }, + "inputs": [ + { + "input_ref": "camera-gate-01", + "state": "degraded", + "error_codes": ["INPUT_DECODE_FAILED"], + "metrics": { "load_percent": 5.2, "queue_depth": 3, "latency_ms": 92.0 } + } + ] +} diff --git a/contracts/runtime-status/v1/examples/valid/offline-last-known.json b/contracts/runtime-status/v1/examples/valid/offline-last-known.json new file mode 100644 index 0000000..8589cbd --- /dev/null +++ b/contracts/runtime-status/v1/examples/valid/offline-last-known.json @@ -0,0 +1,16 @@ +{ + "schema_version": "runtime-status/v1", + "status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d104", + "brain_instance_ref": "brain-east-01", + "sequence": 44, + "observed_at": "2026-08-31T00:01:30Z", + "runtime": { "state": "degraded", "version": "1.0.0", "started_at": "2026-08-30T23:55:00Z" }, + "model": { "model_ref": "people-detection", "version": "2026.08.1" }, + "configuration": { "apply_state": "applied", "applied_revision": "sense-rev-21", "error_code": null }, + "health": { + "overall": "degraded", + "error_codes": ["INPUT_UNREACHABLE"], + "metrics": { "load_percent": 30.0, "queue_depth": 1, "latency_ms": 30.0 } + }, + "inputs": [] +} diff --git a/contracts/runtime-status/v1/examples/valid/recovered.json b/contracts/runtime-status/v1/examples/valid/recovered.json new file mode 100644 index 0000000..0624156 --- /dev/null +++ b/contracts/runtime-status/v1/examples/valid/recovered.json @@ -0,0 +1,16 @@ +{ + "schema_version": "runtime-status/v1", + "status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d105", + "brain_instance_ref": "brain-east-01", + "sequence": 45, + "observed_at": "2026-08-31T00:04:00Z", + "runtime": { "state": "running", "version": "1.0.0", "started_at": "2026-08-30T23:55:00Z" }, + "model": { "model_ref": "people-detection", "version": "2026.08.1" }, + "configuration": { "apply_state": "applied", "applied_revision": "sense-rev-21", "error_code": null }, + "health": { + "overall": "healthy", + "error_codes": [], + "metrics": { "load_percent": 36.0, "queue_depth": 0, "latency_ms": 20.0 } + }, + "inputs": [] +} diff --git a/contracts/runtime-status/v1/examples/valid/running.json b/contracts/runtime-status/v1/examples/valid/running.json new file mode 100644 index 0000000..6ef851f --- /dev/null +++ b/contracts/runtime-status/v1/examples/valid/running.json @@ -0,0 +1,23 @@ +{ + "schema_version": "runtime-status/v1", + "status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d101", + "brain_instance_ref": "brain-east-01", + "sequence": 41, + "observed_at": "2026-08-31T00:00:00Z", + "runtime": { "state": "running", "version": "1.0.0", "started_at": "2026-08-30T23:55:00Z" }, + "model": { "model_ref": "people-detection", "version": "2026.08.1" }, + "configuration": { "apply_state": "applied", "applied_revision": "sense-rev-21", "error_code": null }, + "health": { + "overall": "healthy", + "error_codes": [], + "metrics": { "load_percent": 38.5, "queue_depth": 0, "latency_ms": 21.4 } + }, + "inputs": [ + { + "input_ref": "camera-gate-01", + "state": "running", + "error_codes": [], + "metrics": { "load_percent": 5.2, "queue_depth": 0, "latency_ms": 18.1 } + } + ] +} diff --git a/contracts/runtime-status/v1/mapping.md b/contracts/runtime-status/v1/mapping.md new file mode 100644 index 0000000..d553cbf --- /dev/null +++ b/contracts/runtime-status/v1/mapping.md @@ -0,0 +1,20 @@ +# Brain → Sense mapper 字段责任 + +| 契约字段 | Brain 生产者责任 | Sense 消费者投影责任 | +|---|---|---| +| `schema_version` | 固定发布 `runtime-status/v1` | 先按主版本分派;未知版本不覆盖最后投影 | +| `status_id` | 每次观测生成唯一幂等键 | 去重,不把重复消息当成新观测 | +| `brain_instance_ref` | 发布部署时分配的逻辑引用 | 映射到内部 edge node;不把它当数据库主键 | +| `sequence` | 同实例单调递增 | 拒绝倒序消息,保留最后已知投影 | +| `observed_at` | 发布观测完成时间 | 校验未来偏差;用它推导 fresh/stale,不用接收时间覆盖 | +| `runtime.*` | 报告真实运行状态和脱敏版本 | 校验迁移并形成只读运维状态 | +| `model.*` | 报告逻辑模型引用及版本,不报告文件路径 | 显示版本差异,不推导模型下载或重启命令 | +| `configuration.*` | 报告真实应用结果和 revision | 与 Sense 内部期望 revision 比较;不回写 Brain 状态 | +| `health.*` | 聚合无敏感健康与有界指标 | 展示健康、指标和稳定错误码,不生成业务 Alert | +| `inputs[]` | 按逻辑输入发布安全摘要 | 按 `input_ref` 映射运维投影,不读取视频或检测内容 | + +## 契约测试责任 + +- Brain:对所有发布消息执行 Schema 校验;覆盖各运行状态、配置应用结果、降级/失败以及敏感字段拒绝。 +- Sense:使用同一有效/无效样例;覆盖版本分派、幂等与倒序、30 秒未来偏差、90 秒陈旧边界、状态迁移、offline/recovery、revision mismatch 及回退不覆盖最后投影。 +- 协调契约:`contracts/tests/runtime-status-v1/test_contract.py` 是双方最小共同测试。产品 adapter 仍需在各自工单中增加本地模型映射测试。 diff --git a/contracts/runtime-status/v1/runtime-status.schema.json b/contracts/runtime-status/v1/runtime-status.schema.json new file mode 100644 index 0000000..77b57ce --- /dev/null +++ b/contracts/runtime-status/v1/runtime-status.schema.json @@ -0,0 +1,164 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://yovision.local/contracts/runtime-status/v1/runtime-status.schema.json", + "title": "YoVision Brain runtime status v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "status_id", + "brain_instance_ref", + "sequence", + "observed_at", + "runtime", + "model", + "configuration", + "health", + "inputs" + ], + "properties": { + "schema_version": { "const": "runtime-status/v1" }, + "status_id": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "brain_instance_ref": { "$ref": "#/$defs/logicalRef" }, + "sequence": { "type": "integer", "minimum": 0 }, + "observed_at": { "type": "string", "format": "date-time" }, + "runtime": { + "type": "object", + "additionalProperties": false, + "required": ["state", "version"], + "properties": { + "state": { "$ref": "#/$defs/runtimeState" }, + "version": { "$ref": "#/$defs/version" }, + "started_at": { "type": ["string", "null"], "format": "date-time" } + } + }, + "model": { + "type": "object", + "additionalProperties": false, + "required": ["model_ref", "version"], + "properties": { + "model_ref": { "$ref": "#/$defs/logicalRef" }, + "version": { "$ref": "#/$defs/version" } + } + }, + "configuration": { + "type": "object", + "additionalProperties": false, + "required": ["apply_state", "applied_revision"], + "properties": { + "apply_state": { + "type": "string", + "enum": ["not_configured", "applying", "applied", "rejected"] + }, + "applied_revision": { + "type": ["string", "null"], + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$" + }, + "error_code": { "$ref": "#/$defs/nullableErrorCode" } + }, + "allOf": [ + { + "if": { + "required": ["apply_state"], + "properties": { "apply_state": { "const": "not_configured" } } + }, + "then": { "properties": { "applied_revision": { "type": "null" } } } + }, + { + "if": { + "required": ["apply_state"], + "properties": { "apply_state": { "const": "applied" } } + }, + "then": { "properties": { "applied_revision": { "type": "string", "minLength": 1 } } } + }, + { + "if": { + "required": ["apply_state"], + "properties": { "apply_state": { "const": "rejected" } } + }, + "then": { + "required": ["error_code"], + "properties": { "error_code": { "$ref": "#/$defs/errorCode" } } + } + } + ] + }, + "health": { + "type": "object", + "additionalProperties": false, + "required": ["overall", "error_codes", "metrics"], + "properties": { + "overall": { + "type": "string", + "enum": ["healthy", "degraded", "unhealthy"] + }, + "error_codes": { + "type": "array", + "uniqueItems": true, + "maxItems": 32, + "items": { "$ref": "#/$defs/errorCode" } + }, + "metrics": { "$ref": "#/$defs/metrics" } + } + }, + "inputs": { + "type": "array", + "maxItems": 4096, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["input_ref", "state", "error_codes", "metrics"], + "properties": { + "input_ref": { "$ref": "#/$defs/logicalRef" }, + "state": { "$ref": "#/$defs/runtimeState" }, + "error_codes": { + "type": "array", + "uniqueItems": true, + "maxItems": 16, + "items": { "$ref": "#/$defs/errorCode" } + }, + "metrics": { "$ref": "#/$defs/metrics" } + } + } + } + }, + "$defs": { + "logicalRef": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$" + }, + "version": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$" + }, + "runtimeState": { + "type": "string", + "enum": ["unconfigured", "starting", "running", "degraded", "failed", "stopped"] + }, + "errorCode": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]{2,63}$" + }, + "nullableErrorCode": { + "type": ["string", "null"], + "pattern": "^[A-Z][A-Z0-9_]{2,63}$" + }, + "metrics": { + "type": "object", + "additionalProperties": false, + "required": ["load_percent", "queue_depth", "latency_ms"], + "properties": { + "load_percent": { "type": "number", "minimum": 0, "maximum": 100 }, + "queue_depth": { "type": "integer", "minimum": 0 }, + "latency_ms": { "type": "number", "minimum": 0 } + } + } + } +} diff --git a/contracts/tests/runtime-status-v1/README.md b/contracts/tests/runtime-status-v1/README.md new file mode 100644 index 0000000..d20ed15 --- /dev/null +++ b/contracts/tests/runtime-status-v1/README.md @@ -0,0 +1,9 @@ +# runtime-status/v1 契约测试 + +从仓库根目录运行: + +```powershell +python -m unittest discover -s contracts/tests/runtime-status-v1 -p "test_*.py" -v +``` + +测试只使用 Python 标准库,不安装依赖、不访问网络。它对冻结 Schema 的已用关键字执行验证,并覆盖状态迁移、时间/陈旧边界、offline/recovery、配置 revision、未知主版本、倒序消息和敏感字段拒绝。产品 adapter 还需在各自工单中运行本地模型映射测试。 diff --git a/contracts/tests/runtime-status-v1/test_contract.py b/contracts/tests/runtime-status-v1/test_contract.py new file mode 100644 index 0000000..2cc8c1f --- /dev/null +++ b/contracts/tests/runtime-status-v1/test_contract.py @@ -0,0 +1,274 @@ +import copy +import json +import re +import unittest +from datetime import datetime, timedelta, timezone +from pathlib import Path + + +TEST_DIR = Path(__file__).resolve().parent +CONTRACT_DIR = TEST_DIR.parents[1] / "runtime-status" / "v1" +SCHEMA = json.loads((CONTRACT_DIR / "runtime-status.schema.json").read_text(encoding="utf-8")) +VALID_DIR = CONTRACT_DIR / "examples" / "valid" +INVALID_DIR = CONTRACT_DIR / "examples" / "invalid" + + +def parse_datetime(value): + if not isinstance(value, str): + raise ValueError("not a string") + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + if parsed.tzinfo is None: + raise ValueError("timezone is required") + return parsed.astimezone(timezone.utc) + + +def matches_type(value, expected): + if expected == "null": + return value is None + if expected == "object": + return isinstance(value, dict) + if expected == "array": + return isinstance(value, list) + if expected == "string": + return isinstance(value, str) + if expected == "integer": + return isinstance(value, int) and not isinstance(value, bool) + if expected == "number": + return isinstance(value, (int, float)) and not isinstance(value, bool) + if expected == "boolean": + return isinstance(value, bool) + raise AssertionError(f"unsupported schema type in test validator: {expected}") + + +def resolve_ref(ref): + if not ref.startswith("#/"): + raise AssertionError(f"external refs are not supported: {ref}") + node = SCHEMA + for part in ref[2:].split("/"): + node = node[part.replace("~1", "/").replace("~0", "~")] + return node + + +def validate(instance, schema=None, path="$", errors=None): + schema = SCHEMA if schema is None else schema + errors = [] if errors is None else errors + if "$ref" in schema: + return validate(instance, resolve_ref(schema["$ref"]), path, errors) + + for subschema in schema.get("allOf", []): + validate(instance, subschema, path, errors) + if "if" in schema: + condition_errors = validate(instance, schema["if"], path, []) + branch = schema.get("then") if not condition_errors else schema.get("else") + if branch is not None: + validate(instance, branch, path, errors) + + if "type" in schema: + allowed = schema["type"] if isinstance(schema["type"], list) else [schema["type"]] + if not any(matches_type(instance, expected) for expected in allowed): + errors.append(f"{path}: expected {allowed}") + return errors + + if "const" in schema and instance != schema["const"]: + errors.append(f"{path}: expected constant {schema['const']!r}") + if "enum" in schema and instance not in schema["enum"]: + errors.append(f"{path}: value is not in enum") + + if isinstance(instance, dict): + required = schema.get("required", []) + for name in required: + if name not in instance: + errors.append(f"{path}: missing required property {name}") + properties = schema.get("properties", {}) + if schema.get("additionalProperties") is False: + for name in instance: + if name not in properties: + errors.append(f"{path}: additional property {name}") + for name, value in instance.items(): + if name in properties: + validate(value, properties[name], f"{path}.{name}", errors) + + if isinstance(instance, list): + if "maxItems" in schema and len(instance) > schema["maxItems"]: + errors.append(f"{path}: too many items") + if schema.get("uniqueItems"): + encoded = [json.dumps(item, sort_keys=True) for item in instance] + if len(encoded) != len(set(encoded)): + errors.append(f"{path}: duplicate items") + if "items" in schema: + for index, value in enumerate(instance): + validate(value, schema["items"], f"{path}[{index}]", errors) + + if isinstance(instance, str): + if "minLength" in schema and len(instance) < schema["minLength"]: + errors.append(f"{path}: string is too short") + if "maxLength" in schema and len(instance) > schema["maxLength"]: + errors.append(f"{path}: string is too long") + if "pattern" in schema and re.fullmatch(schema["pattern"], instance) is None: + errors.append(f"{path}: pattern mismatch") + if schema.get("format") == "date-time": + try: + parse_datetime(instance) + except (TypeError, ValueError): + errors.append(f"{path}: invalid date-time") + + if isinstance(instance, (int, float)) and not isinstance(instance, bool): + if "minimum" in schema and instance < schema["minimum"]: + errors.append(f"{path}: below minimum") + if "maximum" in schema and instance > schema["maximum"]: + errors.append(f"{path}: above maximum") + return errors + + +def load(path): + return json.loads(path.read_text(encoding="utf-8")) + + +def freshness(observed_at, evaluation_time): + age = evaluation_time - parse_datetime(observed_at) + if age < timedelta(seconds=-30): + return "future_rejected" + return "stale" if age > timedelta(seconds=90) else "fresh" + + +ALLOWED_TRANSITIONS = { + "unconfigured": {"starting", "stopped"}, + "starting": {"running", "degraded", "failed", "stopped"}, + "running": {"degraded", "failed", "stopped"}, + "degraded": {"running", "failed", "stopped"}, + "failed": {"starting", "stopped"}, + "stopped": {"starting", "unconfigured"}, +} + + +def may_transition(previous, current): + return previous == current or current in ALLOWED_TRANSITIONS[previous] + + +def may_replace(previous, candidate, evaluation_time): + if candidate["schema_version"] != "runtime-status/v1": + return False + if validate(candidate): + return False + if freshness(candidate["observed_at"], evaluation_time) == "future_rejected": + return False + if candidate["brain_instance_ref"] != previous["brain_instance_ref"]: + return False + if candidate["sequence"] <= previous["sequence"]: + return False + return may_transition(previous["runtime"]["state"], candidate["runtime"]["state"]) + + +class RuntimeStatusV1ContractTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.running = load(VALID_DIR / "running.json") + + def test_schema_declares_frozen_version_and_closed_objects(self): + self.assertEqual(SCHEMA["$schema"], "https://json-schema.org/draft/2020-12/schema") + self.assertEqual(SCHEMA["properties"]["schema_version"]["const"], "runtime-status/v1") + self.assertFalse(SCHEMA["additionalProperties"]) + for name in ("runtime", "model", "configuration", "health"): + self.assertFalse(SCHEMA["properties"][name]["additionalProperties"]) + + def test_all_valid_examples_satisfy_schema(self): + paths = sorted(VALID_DIR.glob("*.json")) + self.assertGreaterEqual(len(paths), 5) + for path in paths: + with self.subTest(path=path.name): + self.assertEqual(validate(load(path)), []) + + def test_all_invalid_examples_are_rejected(self): + paths = sorted(INVALID_DIR.glob("*.json")) + self.assertGreaterEqual(len(paths), 5) + for path in paths: + with self.subTest(path=path.name): + self.assertNotEqual(validate(load(path)), []) + + def test_every_runtime_state_is_schema_valid(self): + for state in ALLOWED_TRANSITIONS: + message = copy.deepcopy(self.running) + message["runtime"]["state"] = state + with self.subTest(state=state): + self.assertEqual(validate(message), []) + + def test_state_transition_matrix(self): + self.assertTrue(may_transition("unconfigured", "starting")) + self.assertTrue(may_transition("starting", "running")) + self.assertTrue(may_transition("running", "degraded")) + self.assertTrue(may_transition("degraded", "running")) + self.assertTrue(may_transition("running", "failed")) + self.assertTrue(may_transition("failed", "stopped")) + self.assertFalse(may_transition("unconfigured", "running")) + self.assertFalse(may_transition("stopped", "running")) + + def test_stale_and_future_boundaries(self): + observed = parse_datetime(self.running["observed_at"]) + self.assertEqual(freshness(self.running["observed_at"], observed + timedelta(seconds=90)), "fresh") + self.assertEqual(freshness(self.running["observed_at"], observed + timedelta(seconds=91)), "stale") + self.assertEqual(freshness(self.running["observed_at"], observed - timedelta(seconds=30)), "fresh") + self.assertEqual(freshness(self.running["observed_at"], observed - timedelta(seconds=31)), "future_rejected") + + def test_offline_keeps_last_known_and_recovery_replaces_it(self): + last_known = load(VALID_DIR / "offline-last-known.json") + evaluation = parse_datetime(last_known["observed_at"]) + timedelta(seconds=180) + self.assertEqual(freshness(last_known["observed_at"], evaluation), "stale") + self.assertEqual(last_known["runtime"]["state"], "degraded") + recovered = load(VALID_DIR / "recovered.json") + self.assertTrue(may_replace(last_known, recovered, parse_datetime(recovered["observed_at"]))) + + def test_unknown_version_and_out_of_order_do_not_replace_projection(self): + unknown = load(INVALID_DIR / "unknown-major.json") + evaluation = parse_datetime(self.running["observed_at"]) + self.assertFalse(may_replace(self.running, unknown, evaluation)) + older = copy.deepcopy(self.running) + older["sequence"] = self.running["sequence"] - 1 + self.assertFalse(may_replace(self.running, older, evaluation)) + + def test_configuration_revision_mismatch_is_consumer_derived(self): + message = load(VALID_DIR / "config-mismatch.json") + desired_revision = "sense-rev-21" + self.assertEqual(validate(message), []) + self.assertNotEqual(message["configuration"]["applied_revision"], desired_revision) + self.assertNotIn("desired_revision", message["configuration"]) + + def test_configuration_apply_state_invariants(self): + cases = [ + ("not_configured", "rev-forbidden", None), + ("applied", None, None), + ("rejected", "rev-old", None), + ] + for apply_state, revision, error_code in cases: + message = copy.deepcopy(self.running) + message["configuration"] = { + "apply_state": apply_state, + "applied_revision": revision, + "error_code": error_code, + } + with self.subTest(apply_state=apply_state): + self.assertNotEqual(validate(message), []) + + def test_sensitive_and_business_fields_are_rejected_by_name(self): + for forbidden in ("access_token", "password", "credential", "internal_path", "stack", "user_session", "video", "face", "alert"): + message = copy.deepcopy(self.running) + message[forbidden] = "forbidden" + with self.subTest(forbidden=forbidden): + self.assertTrue(any("additional property" in error for error in validate(message))) + + def test_logical_references_reject_paths(self): + for value in ("C:\\models\\private.pt", "/srv/models/private.pt", "../private.pt"): + message = copy.deepcopy(self.running) + message["model"]["model_ref"] = value + with self.subTest(value=value): + self.assertNotEqual(validate(message), []) + + def test_mapper_responsibilities_are_documented(self): + mapping = (CONTRACT_DIR / "mapping.md").read_text(encoding="utf-8") + for field in ("schema_version", "status_id", "brain_instance_ref", "sequence", "observed_at", "runtime.*", "model.*", "configuration.*", "health.*", "inputs[]"): + self.assertIn(f"`{field}`", mapping) + self.assertIn("Brain", mapping) + self.assertIn("Sense", mapping) + + +if __name__ == "__main__": + unittest.main()