From a22d3ce0f16912af699af3269f7595bb8df53390 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Mon, 31 Aug 2026 08:59:48 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=AF=B9=E9=BD=90=E5=A4=9A=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E8=BF=90=E8=A1=8C=E7=8A=B6=E6=80=81=E8=AF=AD=E4=B9=89?= =?UTF-8?q?=20(#149)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- contracts/runtime-status/v1/README.md | 9 +- .../v1/examples/invalid/business-alert.json | 4 +- .../v1/examples/invalid/credential.json | 4 +- .../examples/invalid/duplicate-config-id.json | 19 ++++ .../v1/examples/invalid/internal-path.json | 4 +- .../v1/examples/invalid/unknown-major.json | 4 +- .../v1/examples/invalid/user-session.json | 4 +- .../v1/examples/valid/config-mismatch.json | 4 +- .../examples/valid/configuration-states.json | 21 +++++ .../v1/examples/valid/degraded.json | 5 +- .../examples/valid/empty-configurations.json | 16 ++++ .../v1/examples/valid/offline-last-known.json | 4 +- .../v1/examples/valid/recovered.json | 4 +- .../v1/examples/valid/running.json | 4 +- contracts/runtime-status/v1/mapping.md | 2 +- .../v1/runtime-status.schema.json | 82 +++++++++-------- contracts/tests/runtime-status-v1/README.md | 2 +- .../tests/runtime-status-v1/test_contract.py | 88 ++++++++++++++----- 18 files changed, 207 insertions(+), 73 deletions(-) create mode 100644 contracts/runtime-status/v1/examples/invalid/duplicate-config-id.json create mode 100644 contracts/runtime-status/v1/examples/valid/configuration-states.json create mode 100644 contracts/runtime-status/v1/examples/valid/empty-configurations.json diff --git a/contracts/runtime-status/v1/README.md b/contracts/runtime-status/v1/README.md index 531699b..3b52213 100644 --- a/contracts/runtime-status/v1/README.md +++ b/contracts/runtime-status/v1/README.md @@ -25,9 +25,14 @@ Brain 报告的 `runtime.state` 和每个输入的 `state` 使用同一枚举: 首次有效消息可为任一状态;Sense 只校验同实例连续消息的迁移。`stale`、`offline_*` 不参与 Brain 状态迁移。恢复连接后,只有 schema、时间、sequence 和状态迁移均有效的新消息才能更新投影。 -## 配置 revision +## 配置流与 revision -`configuration.applied_revision` 只表示 Brain 已实际应用的配置。Sense 将它与自己投递成功的期望 revision 比较:相等为 synchronized,不相等为 mismatch。Sense 的期望 revision 不进入本消息,避免产生第二事实源。`not_configured` 时 revision 必须为 null;`applied` 时必须非空;`rejected` 必须带错误码。 +`configurations` 按 #148 的配置流报告,可以为空,也可以包含多个配置。每项 `config_id` 必须唯一,并与 `yovision.source-config/v1` 的 `config_id` 一致;重复 ID 使整条状态无效,不能覆盖最后已知投影。`applied_revision` 是 Brain 对该配置流已实际应用的 integer revision。Sense 必须逐个 `config_id` 与自己已投递的期望 revision 比较:相等为 synchronized,不相等为 mismatch;Sense 的期望 revision 不进入本消息,避免产生第二事实源。 + +- `not_configured`:尚未应用该配置,revision 必须为 null。 +- `applying`:正在应用;revision 为 null 或仍在运行的上一个 revision。 +- `applied`:应用成功,revision 必须是大于等于 1 的整数。 +- `rejected`:本次应用被拒绝;revision 为 null 或最后成功 revision,且必须带稳定错误码。 ## 兼容与回退 diff --git a/contracts/runtime-status/v1/examples/invalid/business-alert.json b/contracts/runtime-status/v1/examples/invalid/business-alert.json index ee90e7d..cea15d8 100644 --- a/contracts/runtime-status/v1/examples/invalid/business-alert.json +++ b/contracts/runtime-status/v1/examples/invalid/business-alert.json @@ -6,7 +6,9 @@ "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 }, + "configurations": [ + { "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 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 index c47f34f..9840444 100644 --- a/contracts/runtime-status/v1/examples/invalid/credential.json +++ b/contracts/runtime-status/v1/examples/invalid/credential.json @@ -6,7 +6,9 @@ "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 }, + "configurations": [ + { "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 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/duplicate-config-id.json b/contracts/runtime-status/v1/examples/invalid/duplicate-config-id.json new file mode 100644 index 0000000..13972e6 --- /dev/null +++ b/contracts/runtime-status/v1/examples/invalid/duplicate-config-id.json @@ -0,0 +1,19 @@ +{ + "schema_version": "yovision.runtime-status/v1", + "status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d206", + "brain_instance_ref": "brain-east-01", + "sequence": 47, + "observed_at": "2026-08-31T00:05:00Z", + "runtime": { "state": "degraded", "version": "1.0.0", "started_at": "2026-08-30T23:55:00Z" }, + "model": { "model_ref": "people-detection", "version": "2026.08.1" }, + "configurations": [ + { "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 21, "error_code": null }, + { "config_id": "gate-primary", "apply_state": "rejected", "applied_revision": 20, "error_code": "CONFIG_INVALID" } + ], + "health": { + "overall": "degraded", + "error_codes": ["CONFIG_INVALID"], + "metrics": { "load_percent": 42, "queue_depth": 1, "latency_ms": 31 } + }, + "inputs": [] +} diff --git a/contracts/runtime-status/v1/examples/invalid/internal-path.json b/contracts/runtime-status/v1/examples/invalid/internal-path.json index ddc8aa3..b021e77 100644 --- a/contracts/runtime-status/v1/examples/invalid/internal-path.json +++ b/contracts/runtime-status/v1/examples/invalid/internal-path.json @@ -6,7 +6,9 @@ "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 }, + "configurations": [ + { "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 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 index 40b534f..7b295f5 100644 --- a/contracts/runtime-status/v1/examples/invalid/unknown-major.json +++ b/contracts/runtime-status/v1/examples/invalid/unknown-major.json @@ -6,7 +6,9 @@ "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 }, + "configurations": [ + { "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 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 index d711852..79bdb42 100644 --- a/contracts/runtime-status/v1/examples/invalid/user-session.json +++ b/contracts/runtime-status/v1/examples/invalid/user-session.json @@ -6,7 +6,9 @@ "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 }, + "configurations": [ + { "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 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 index dcad8d2..a188c8c 100644 --- a/contracts/runtime-status/v1/examples/valid/config-mismatch.json +++ b/contracts/runtime-status/v1/examples/valid/config-mismatch.json @@ -6,7 +6,9 @@ "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 }, + "configurations": [ + { "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 20, "error_code": null } + ], "health": { "overall": "healthy", "error_codes": [], diff --git a/contracts/runtime-status/v1/examples/valid/configuration-states.json b/contracts/runtime-status/v1/examples/valid/configuration-states.json new file mode 100644 index 0000000..b82522b --- /dev/null +++ b/contracts/runtime-status/v1/examples/valid/configuration-states.json @@ -0,0 +1,21 @@ +{ + "schema_version": "yovision.runtime-status/v1", + "status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d107", + "brain_instance_ref": "brain-east-01", + "sequence": 46, + "observed_at": "2026-08-31T00:04:30Z", + "runtime": { "state": "degraded", "version": "1.0.0", "started_at": "2026-08-30T23:55:00Z" }, + "model": { "model_ref": "people-detection", "version": "2026.08.1" }, + "configurations": [ + { "config_id": "new-stream", "apply_state": "not_configured", "applied_revision": null, "error_code": null }, + { "config_id": "yard-secondary", "apply_state": "applying", "applied_revision": 8, "error_code": null }, + { "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 21, "error_code": null }, + { "config_id": "warehouse", "apply_state": "rejected", "applied_revision": 3, "error_code": "CONFIG_INVALID" } + ], + "health": { + "overall": "degraded", + "error_codes": ["CONFIG_INVALID"], + "metrics": { "load_percent": 42, "queue_depth": 1, "latency_ms": 31 } + }, + "inputs": [] +} diff --git a/contracts/runtime-status/v1/examples/valid/degraded.json b/contracts/runtime-status/v1/examples/valid/degraded.json index f3d81c3..050ff6f 100644 --- a/contracts/runtime-status/v1/examples/valid/degraded.json +++ b/contracts/runtime-status/v1/examples/valid/degraded.json @@ -6,7 +6,10 @@ "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 }, + "configurations": [ + { "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 21, "error_code": null }, + { "config_id": "yard-secondary", "apply_state": "applying", "applied_revision": 8, "error_code": null } + ], "health": { "overall": "degraded", "error_codes": ["RESOURCE_PRESSURE"], diff --git a/contracts/runtime-status/v1/examples/valid/empty-configurations.json b/contracts/runtime-status/v1/examples/valid/empty-configurations.json new file mode 100644 index 0000000..4cfd8c8 --- /dev/null +++ b/contracts/runtime-status/v1/examples/valid/empty-configurations.json @@ -0,0 +1,16 @@ +{ + "schema_version": "yovision.runtime-status/v1", + "status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d106", + "brain_instance_ref": "brain-east-02", + "sequence": 1, + "observed_at": "2026-08-31T00:00:00Z", + "runtime": { "state": "unconfigured", "version": "1.0.0", "started_at": null }, + "model": { "model_ref": "people-detection", "version": "2026.08.1" }, + "configurations": [], + "health": { + "overall": "healthy", + "error_codes": [], + "metrics": { "load_percent": 0, "queue_depth": 0, "latency_ms": 0 } + }, + "inputs": [] +} diff --git a/contracts/runtime-status/v1/examples/valid/offline-last-known.json b/contracts/runtime-status/v1/examples/valid/offline-last-known.json index 3e9859c..8934e8e 100644 --- a/contracts/runtime-status/v1/examples/valid/offline-last-known.json +++ b/contracts/runtime-status/v1/examples/valid/offline-last-known.json @@ -6,7 +6,9 @@ "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 }, + "configurations": [ + { "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 21, "error_code": null } + ], "health": { "overall": "degraded", "error_codes": ["INPUT_UNREACHABLE"], diff --git a/contracts/runtime-status/v1/examples/valid/recovered.json b/contracts/runtime-status/v1/examples/valid/recovered.json index ed33b39..6a4cd77 100644 --- a/contracts/runtime-status/v1/examples/valid/recovered.json +++ b/contracts/runtime-status/v1/examples/valid/recovered.json @@ -6,7 +6,9 @@ "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 }, + "configurations": [ + { "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 21, "error_code": null } + ], "health": { "overall": "healthy", "error_codes": [], diff --git a/contracts/runtime-status/v1/examples/valid/running.json b/contracts/runtime-status/v1/examples/valid/running.json index 8eae210..54b17cb 100644 --- a/contracts/runtime-status/v1/examples/valid/running.json +++ b/contracts/runtime-status/v1/examples/valid/running.json @@ -6,7 +6,9 @@ "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 }, + "configurations": [ + { "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 21, "error_code": null } + ], "health": { "overall": "healthy", "error_codes": [], diff --git a/contracts/runtime-status/v1/mapping.md b/contracts/runtime-status/v1/mapping.md index 0e7ab0d..8429b6e 100644 --- a/contracts/runtime-status/v1/mapping.md +++ b/contracts/runtime-status/v1/mapping.md @@ -9,7 +9,7 @@ | `observed_at` | 发布观测完成时间 | 校验未来偏差;用它推导 fresh/stale,不用接收时间覆盖 | | `runtime.*` | 报告真实运行状态和脱敏版本 | 校验迁移并形成只读运维状态 | | `model.*` | 报告逻辑模型引用及版本,不报告文件路径 | 显示版本差异,不推导模型下载或重启命令 | -| `configuration.*` | 报告真实应用结果和 revision | 与 Sense 内部期望 revision 比较;不回写 Brain 状态 | +| `configurations[]` | 每个 `config_id` 报告真实应用结果和 integer revision;同一消息内 ID 唯一 | 按 `config_id` 与 Sense 内部期望 revision 比较;拒绝重复 ID,不回写 Brain 状态 | | `health.*` | 聚合无敏感健康与有界指标 | 展示健康、指标和稳定错误码,不生成业务 Alert | | `inputs[]` | 按逻辑输入发布安全摘要 | 按 `input_ref` 映射运维投影,不读取视频或检测内容 | diff --git a/contracts/runtime-status/v1/runtime-status.schema.json b/contracts/runtime-status/v1/runtime-status.schema.json index 271db00..6386c7e 100644 --- a/contracts/runtime-status/v1/runtime-status.schema.json +++ b/contracts/runtime-status/v1/runtime-status.schema.json @@ -12,7 +12,7 @@ "observed_at", "runtime", "model", - "configuration", + "configurations", "health", "inputs" ], @@ -44,47 +44,49 @@ "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" } } + "configurations": { + "type": "array", + "maxItems": 4096, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["config_id", "apply_state", "applied_revision", "error_code"], + "properties": { + "config_id": { "$ref": "#/$defs/configId" }, + "apply_state": { + "type": "string", + "enum": ["not_configured", "applying", "applied", "rejected"] }, - "then": { "properties": { "applied_revision": { "type": "null" } } } - }, - { - "if": { - "required": ["apply_state"], - "properties": { "apply_state": { "const": "applied" } } + "applied_revision": { + "type": ["integer", "null"], + "minimum": 1 }, - "then": { "properties": { "applied_revision": { "type": "string", "minLength": 1 } } } + "error_code": { "$ref": "#/$defs/nullableErrorCode" } }, - { - "if": { - "required": ["apply_state"], - "properties": { "apply_state": { "const": "rejected" } } + "allOf": [ + { + "if": { + "required": ["apply_state"], + "properties": { "apply_state": { "const": "not_configured" } } + }, + "then": { "properties": { "applied_revision": { "type": "null" } } } }, - "then": { - "required": ["error_code"], - "properties": { "error_code": { "$ref": "#/$defs/errorCode" } } + { + "if": { + "required": ["apply_state"], + "properties": { "apply_state": { "const": "applied" } } + }, + "then": { "properties": { "applied_revision": { "type": "integer", "minimum": 1 } } } + }, + { + "if": { + "required": ["apply_state"], + "properties": { "apply_state": { "const": "rejected" } } + }, + "then": { "properties": { "error_code": { "$ref": "#/$defs/errorCode" } } } } - } - ] + ] + } }, "health": { "type": "object", @@ -126,6 +128,12 @@ } }, "$defs": { + "configId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._~-]*$" + }, "logicalRef": { "type": "string", "minLength": 1, diff --git a/contracts/tests/runtime-status-v1/README.md b/contracts/tests/runtime-status-v1/README.md index c4cf129..7ba2527 100644 --- a/contracts/tests/runtime-status-v1/README.md +++ b/contracts/tests/runtime-status-v1/README.md @@ -6,4 +6,4 @@ python -m unittest discover -s contracts/tests/runtime-status-v1 -p "test_*.py" -v ``` -测试只使用 Python 标准库,不安装依赖、不访问网络。它对冻结 Schema 的已用关键字执行验证,并覆盖状态迁移、时间/陈旧边界、offline/recovery、配置 revision、未知主版本、倒序消息和敏感字段拒绝。产品 adapter 还需在各自工单中运行本地模型映射测试。 +测试只使用 Python 标准库,不安装依赖、不访问网络。它对冻结 Schema 的已用关键字执行验证,并覆盖状态迁移、时间/陈旧边界、offline/recovery、空/多配置流、四种配置应用状态、重复 `config_id`、integer revision mismatch、未知主版本、倒序消息、回退保留和敏感字段拒绝。产品 adapter 还需在各自工单中运行本地模型映射测试。 diff --git a/contracts/tests/runtime-status-v1/test_contract.py b/contracts/tests/runtime-status-v1/test_contract.py index 49ac3ae..8cfd217 100644 --- a/contracts/tests/runtime-status-v1/test_contract.py +++ b/contracts/tests/runtime-status-v1/test_contract.py @@ -124,6 +124,17 @@ def load(path): return json.loads(path.read_text(encoding="utf-8")) +def validate_contract(instance): + errors = validate(instance) + configurations = instance.get("configurations") if isinstance(instance, dict) else None + if isinstance(configurations, list): + config_ids = [item.get("config_id") for item in configurations if isinstance(item, dict)] + duplicates = {config_id for config_id in config_ids if config_ids.count(config_id) > 1} + if duplicates: + errors.append(f"$.configurations: duplicate config_id {sorted(duplicates)!r}") + return errors + + def freshness(observed_at, evaluation_time): age = evaluation_time - parse_datetime(observed_at) if age < timedelta(seconds=-30): @@ -148,7 +159,7 @@ def may_transition(previous, current): def may_replace(previous, candidate, evaluation_time): if candidate["schema_version"] != "yovision.runtime-status/v1": return False - if validate(candidate): + if validate_contract(candidate): return False if freshness(candidate["observed_at"], evaluation_time) == "future_rejected": return False @@ -168,29 +179,30 @@ class RuntimeStatusV1ContractTests(unittest.TestCase): self.assertEqual(SCHEMA["$schema"], "https://json-schema.org/draft/2020-12/schema") self.assertEqual(SCHEMA["properties"]["schema_version"]["const"], "yovision.runtime-status/v1") self.assertFalse(SCHEMA["additionalProperties"]) - for name in ("runtime", "model", "configuration", "health"): + for name in ("runtime", "model", "health"): self.assertFalse(SCHEMA["properties"][name]["additionalProperties"]) + self.assertFalse(SCHEMA["properties"]["configurations"]["items"]["additionalProperties"]) def test_all_valid_examples_satisfy_schema(self): paths = sorted(VALID_DIR.glob("*.json")) - self.assertGreaterEqual(len(paths), 5) + self.assertGreaterEqual(len(paths), 7) for path in paths: with self.subTest(path=path.name): - self.assertEqual(validate(load(path)), []) + self.assertEqual(validate_contract(load(path)), []) def test_all_invalid_examples_are_rejected(self): paths = sorted(INVALID_DIR.glob("*.json")) - self.assertGreaterEqual(len(paths), 5) + self.assertGreaterEqual(len(paths), 6) for path in paths: with self.subTest(path=path.name): - self.assertNotEqual(validate(load(path)), []) + self.assertNotEqual(validate_contract(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), []) + self.assertEqual(validate_contract(message), []) def test_state_transition_matrix(self): self.assertTrue(may_transition("unconfigured", "starting")) @@ -224,47 +236,79 @@ class RuntimeStatusV1ContractTests(unittest.TestCase): older = copy.deepcopy(self.running) older["sequence"] = self.running["sequence"] - 1 self.assertFalse(may_replace(self.running, older, evaluation)) + duplicate = load(INVALID_DIR / "duplicate-config-id.json") + self.assertFalse(may_replace(self.running, duplicate, parse_datetime(duplicate["observed_at"]))) 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"]) + desired_revisions = {"gate-primary": 21} + self.assertEqual(validate_contract(message), []) + report = message["configurations"][0] + self.assertNotEqual(report["applied_revision"], desired_revisions[report["config_id"]]) + self.assertNotIn("desired_revision", report) def test_configuration_apply_state_invariants(self): - cases = [ - ("not_configured", "rev-forbidden", None), - ("applied", None, None), - ("rejected", "rev-old", None), + valid_cases = [ + ("not_configured", None, None), + ("applying", None, None), + ("applying", 1, None), + ("applied", 1, None), + ("rejected", None, "CONFIG_INVALID"), + ("rejected", 1, "CONFIG_INVALID"), ] - for apply_state, revision, error_code in cases: + for apply_state, revision, error_code in valid_cases: message = copy.deepcopy(self.running) - message["configuration"] = { + message["configurations"] = [{ + "config_id": "gate-primary", "apply_state": apply_state, "applied_revision": revision, "error_code": error_code, - } + }] with self.subTest(apply_state=apply_state): - self.assertNotEqual(validate(message), []) + self.assertEqual(validate_contract(message), []) + + invalid_cases = [ + ("not_configured", 1, None), + ("applied", None, None), + ("rejected", 1, None), + ] + for apply_state, revision, error_code in invalid_cases: + message = copy.deepcopy(self.running) + message["configurations"] = [{ + "config_id": "gate-primary", + "apply_state": apply_state, + "applied_revision": revision, + "error_code": error_code, + }] + with self.subTest(invalid_apply_state=apply_state): + self.assertNotEqual(validate_contract(message), []) + + def test_empty_multiple_and_duplicate_configuration_streams(self): + empty = load(VALID_DIR / "empty-configurations.json") + multiple = load(VALID_DIR / "configuration-states.json") + duplicate = load(INVALID_DIR / "duplicate-config-id.json") + self.assertEqual(validate_contract(empty), []) + self.assertEqual(validate_contract(multiple), []) + self.assertEqual(len(multiple["configurations"]), 4) + self.assertTrue(any("duplicate config_id" in error for error in validate_contract(duplicate))) 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))) + self.assertTrue(any("additional property" in error for error in validate_contract(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), []) + self.assertNotEqual(validate_contract(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[]"): + for field in ("schema_version", "status_id", "brain_instance_ref", "sequence", "observed_at", "runtime.*", "model.*", "configurations[]", "health.*", "inputs[]"): self.assertIn(f"`{field}`", mapping) self.assertIn("Brain", mapping) self.assertIn("Sense", mapping)