Compare commits

..
42 changed files with 816 additions and 747 deletions
-58
View File
@@ -1,58 +0,0 @@
# Standard event contract v1
`yovision.event/v1` is the only shared representation of an anonymous safety event. It is an immutable fact, not a Bell Alert. Bell owns all rule matching, Alert, acknowledgement, close, notification and user/audit state.
## Identity and idempotency
The permanent idempotency key is the exact UTF-8 pair `(producer_id, source_event_id)`. `producer_id` always names the original producer. A Sense gateway/relay sends its own authenticated transport identity and optional `X-YoVision-Relay-ID`, but it must forward both key fields and the business payload unchanged. A retry is not a new event.
After schema validation, calculate `payload_sha256` from the RFC 8785 JSON Canonicalization Scheme representation of the complete Event. The checked-in vector fixes the expected digest for supported implementations. Bell stores key, digest and Bell `event_id` permanently:
- absent key: atomically create Event/Receipt and return `201` with `disposition=created`;
- same key and digest: return the original `event_id` and digest with `200`, `disposition=duplicate`;
- same key but another digest: return `409 idempotency_conflict`, append an audit fact, and mutate neither Event nor Alert;
- identity lookup and insert must share a transaction/unique constraint so concurrent duplicates have the same result.
Canonical timestamps in Event v1 are UTC RFC 3339 with exactly three fractional digits and `Z`. Optional members are omitted, never sent as `null`. Producers must reject non-finite numbers before canonicalization.
## Mapper responsibilities
| Role | Required responsibility | Must not do |
|---|---|---|
| Brain producer mapper | Convert `brain.internal.event-candidate/v1` into stable original identity, logical site/device/profile/rule/region refs, model version and anonymous observation; generate one `source_event_id` once and persist/reuse it across retries. | Expose internal candidate fields, face/person identity, camera credentials, file paths, Alert state, or regenerate identity during retry. |
| Sense producer/evidence mapper | When Sense originates an event, apply the same original-identity rule; map its internal evidence record to a logical evidence reference and own later status resolution. | Put local path, RTSP URL, signed URL, credential or Outbox attempt ID into Event. |
| Sense relay | Authenticate as a transport hop, preserve original `producer_id`, `source_event_id` and payload, retain retry/audit state outside the Event, and return Bell's response unchanged enough for deterministic retry handling. | Replace producer identity, create a new source ID, enrich/reorder semantics, or treat `409`/`422` as a transient retry. |
| Bell consumer mapper | Validate before persistence; canonicalize; enforce permanent idempotency; map the immutable shared Event into Bell's private Event/Receipt and then independently evaluate rules to create an Alert. Unknown evidence becomes degraded evidence, not a rejected Event. | Persist arbitrary extension fields, import producer internals, or accept shared ack/close/notification/user state. |
Field ownership is deliberately narrow:
| Contract fields | Authoritative writer | Relay/Bell responsibility |
|---|---|---|
| `schema_version`, `producer_id`, `source_event_id` | Original Brain or Sense producer mapper | Relay preserves; Bell uses version gate and permanent idempotency key. |
| `site_ref`, `device_ref`, `profile_ref` | Producer mapper from versioned logical configuration | Relay preserves; Bell treats as opaque external refs. |
| `event_type`, `occurred_at`, `severity`, `rule`, `model`, `observation`, `region` | Brain/Sense mapper at the detection decision | Relay preserves; Bell validates and stores the immutable snapshot. |
| `evidence[]` identity and initial status | Evidence-owning producer, normally Sense | Relay preserves; Bell stores the Event snapshot and resolves current metadata separately. |
| `X-YoVision-Relay-ID` | Authenticated Sense transport hop | Bell audits transport metadata outside the immutable Event. |
| `event_id`, `disposition`, `payload_sha256` | Bell ingest boundary | Producer/relay retain the receipt for deterministic retries. |
## Errors, compatibility and fallback
- `400 invalid_event`: schema, canonical form, or sensitive/unknown member violation. Terminal until the producer fixes the payload.
- `409 idempotency_conflict`: same permanent key with a different payload. Terminal and audited; never overwrite the first Event.
- `422 unsupported_schema_version`: unknown major/revision. Terminal for that payload.
- Evidence `pending`, `processing`, `success` and `failed` are valid Event states. Bell keeps the Event and resolves/degrades evidence independently.
v1 is closed (`additionalProperties=false`). Producers may enable a compatible revision only after all relays and Bell validate it. Any removed/renamed required field, changed meaning, enum narrowing, identity/canonicalization change, or new required member publishes a new major path such as `/v2`. During the compatibility window Bell keeps the previous version endpoint. Rollback disables the new producer version and resumes the last accepted version; it does not delete Event, Receipt, Outbox or audit facts.
Unknown-version fallback is explicit: Bell returns `422`; relay records the terminal rejection without rewriting the payload; producer may remap the same internal candidate into a supported v1 payload only if it has not previously assigned that `(producer_id, source_event_id)` to a different canonical payload. Otherwise it must stop and require operator reconciliation.
## Reproducible verification
No third-party package is needed:
```powershell
python contracts/tests/events-v1/test_contract.py
python contracts/tests/evidence-v1/test_contract.py
```
The tests validate Schema/OpenAPI references, mapper fixtures, RFC 8785-compatible canonical vectors used by v1 examples, duplicate/conflict behavior, unknown versions and sensitive-field rejection.
-77
View File
@@ -1,77 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://yovision.local/contracts/events/v1/event.schema.json",
"title": "YoVision anonymous safety event v1",
"type": "object",
"additionalProperties": false,
"required": [
"schema_version", "producer_id", "source_event_id", "site_ref", "device_ref",
"profile_ref", "event_type", "occurred_at", "severity", "rule", "model",
"observation", "region", "evidence"
],
"properties": {
"schema_version": {"const": "yovision.event/v1"},
"producer_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
"source_event_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
"site_ref": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
"device_ref": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
"profile_ref": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
"event_type": {"enum": ["dangerous_area_entered", "directional_line_crossed"]},
"occurred_at": {"type": "string", "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$"},
"severity": {"enum": ["low", "medium", "high", "critical"]},
"rule": {
"type": "object", "additionalProperties": false, "required": ["rule_id", "version"],
"properties": {
"rule_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
"version": {"type": "string", "minLength": 1, "maxLength": 64}
}
},
"model": {
"type": "object", "additionalProperties": false, "required": ["name", "version"],
"properties": {
"name": {"type": "string", "minLength": 1, "maxLength": 128},
"version": {"type": "string", "minLength": 1, "maxLength": 64}
}
},
"observation": {
"type": "object", "additionalProperties": false,
"required": ["track_id", "category", "confidence"],
"properties": {
"track_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
"category": {"enum": ["person", "vehicle", "other"]},
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
"bbox_normalized": {
"type": "array", "minItems": 4, "maxItems": 4,
"items": {"type": "number", "minimum": 0, "maximum": 1}
}
}
},
"region": {
"type": "object", "additionalProperties": false,
"required": ["region_id", "kind"],
"properties": {
"region_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
"kind": {"enum": ["area", "line"]},
"crossing_direction": {"enum": ["a_to_b", "b_to_a"]}
},
"allOf": [
{"if": {"properties": {"kind": {"const": "line"}}, "required": ["kind"]}, "then": {"required": ["crossing_direction"]}},
{"if": {"properties": {"kind": {"const": "area"}}, "required": ["kind"]}, "then": {"not": {"required": ["crossing_direction"]}}}
]
},
"evidence": {
"type": "array", "maxItems": 8, "uniqueItems": true,
"items": {"$ref": "../../evidence/v1/evidence-reference.schema.json"}
}
},
"allOf": [
{
"if": {"properties": {"event_type": {"const": "dangerous_area_entered"}}, "required": ["event_type"]},
"then": {"properties": {"region": {"properties": {"kind": {"const": "area"}}}}}
},
{
"if": {"properties": {"event_type": {"const": "directional_line_crossed"}}, "required": ["event_type"]},
"then": {"properties": {"region": {"properties": {"kind": {"const": "line"}}}}}
}
]
}
@@ -1,26 +0,0 @@
{
"schema_version": "yovision.event/v1",
"producer_id": "brain-school-a",
"source_event_id": "evt-area-20260831-0001",
"site_ref": "site-school-a",
"device_ref": "camera-east-gate",
"profile_ref": "profile-main-stream",
"event_type": "dangerous_area_entered",
"occurred_at": "2026-08-31T00:00:01.125Z",
"severity": "high",
"rule": {"rule_id": "rule-east-danger", "version": "3"},
"model": {"name": "anonymous-detector", "version": "2026.08"},
"observation": {"track_id": "track-0042", "category": "person", "confidence": 0.93, "bbox_normalized": [0.12, 0.2, 0.31, 0.74]},
"region": {"region_id": "region-east-danger", "kind": "area"},
"evidence": [
{
"schema_version": "yovision.evidence-reference/v1",
"evidence_id": "ev-school-east-0001",
"owner_id": "sense-school-a",
"type": "snapshot",
"status": "pending",
"captured_at": "2026-08-31T00:00:01.125Z",
"status_updated_at": "2026-08-31T00:00:01.125Z"
}
]
}
@@ -1,27 +0,0 @@
{
"schema_version": "yovision.event/v1",
"producer_id": "brain-school-a",
"source_event_id": "evt-line-20260831-0002",
"site_ref": "site-school-a",
"device_ref": "camera-north-corridor",
"profile_ref": "profile-main-stream",
"event_type": "directional_line_crossed",
"occurred_at": "2026-08-31T00:03:10.000Z",
"severity": "medium",
"rule": {"rule_id": "rule-north-one-way", "version": "1"},
"model": {"name": "anonymous-detector", "version": "2026.08"},
"observation": {"track_id": "track-0088", "category": "person", "confidence": 0.88},
"region": {"region_id": "line-north-one-way", "kind": "line", "crossing_direction": "b_to_a"},
"evidence": [
{
"schema_version": "yovision.evidence-reference/v1",
"evidence_id": "ev-school-east-0002",
"owner_id": "sense-school-a",
"type": "clip",
"status": "failed",
"captured_at": "2026-08-31T00:03:10.000Z",
"status_updated_at": "2026-08-31T00:03:13.100Z",
"failure": {"code": "processing_failed", "retryable": true}
}
]
}
@@ -1,7 +0,0 @@
{
"event_id": "bell-event-00000042",
"producer_id": "brain-school-a",
"source_event_id": "evt-area-20260831-0001",
"disposition": "duplicate",
"payload_sha256": "4cc1e93820195caf713ea675ff33f178c9d4997dd8a81cb61287e9fea0e3d5e1"
}
@@ -1,5 +0,0 @@
{
"code": "idempotency_conflict",
"message": "idempotency key already belongs to another canonical payload",
"existing_event_id": "bell-event-00000042"
}
@@ -1,5 +0,0 @@
{
"code": "unsupported_schema_version",
"message": "schema_version yovision.event/v2 is not accepted",
"field": "schema_version"
}
@@ -1,15 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://yovision.local/contracts/events/v1/ingest-result.schema.json",
"title": "YoVision Bell event ingest result v1",
"type": "object",
"additionalProperties": false,
"required": ["event_id", "producer_id", "source_event_id", "disposition", "payload_sha256"],
"properties": {
"event_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
"producer_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
"source_event_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
"disposition": {"enum": ["created", "duplicate"]},
"payload_sha256": {"type": "string", "pattern": "^[a-f0-9]{64}$"}
}
}
-22
View File
@@ -1,22 +0,0 @@
{
"openapi": "3.1.0",
"info": {"title": "YoVision standard event ingest API", "version": "1.0.0"},
"paths": {
"/v1/events": {
"post": {
"summary": "Ingest one immutable anonymous safety event",
"parameters": [
{"name": "X-YoVision-Relay-ID", "in": "header", "required": false, "description": "Audited transport hop. A relay must not change producer_id or source_event_id.", "schema": {"type": "string", "maxLength": 128}}
],
"requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "./event.schema.json"}}}},
"responses": {
"201": {"description": "Created", "content": {"application/json": {"schema": {"$ref": "./ingest-result.schema.json"}}}},
"200": {"description": "Exact duplicate; returns the original Bell Event identity", "content": {"application/json": {"schema": {"$ref": "./ingest-result.schema.json"}}}},
"400": {"description": "Invalid or sensitive payload", "content": {"application/problem+json": {"schema": {"$ref": "./problem.schema.json"}}}},
"409": {"description": "Same idempotency key with a different canonical payload", "content": {"application/problem+json": {"schema": {"$ref": "./problem.schema.json"}}}},
"422": {"description": "Unsupported schema major version", "content": {"application/problem+json": {"schema": {"$ref": "./problem.schema.json"}}}}
}
}
}
}
}
-14
View File
@@ -1,14 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://yovision.local/contracts/events/v1/problem.schema.json",
"title": "YoVision contract problem v1",
"type": "object",
"additionalProperties": false,
"required": ["code", "message"],
"properties": {
"code": {"enum": ["invalid_event", "unsupported_schema_version", "idempotency_conflict", "evidence_not_found", "evidence_expired"]},
"message": {"type": "string", "minLength": 1, "maxLength": 512},
"field": {"type": "string", "pattern": "^[A-Za-z0-9_.\\[\\]-]{1,128}$"},
"existing_event_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"}
}
}
-30
View File
@@ -1,30 +0,0 @@
# Evidence reference contract v1
This contract shares metadata about a logical evidence object. It never grants object access. `owner_id` identifies the service that owns resolution; `evidence_id` is opaque to every consumer. Neither field may be interpreted as a URL or local path.
## State and degradation
- `pending`: capture was accepted but no processing started.
- `processing`: capture or encoding is in progress.
- `success`: capture completed; `content_type` and SHA-256 `integrity` are required. Access authorization is negotiated outside this payload by the machine-identity/connector work.
- `failed`: `failure.code` and `retryable` are required. Bell keeps the immutable Event and renders evidence unavailable; it must not reject or close the Alert because evidence failed.
- HTTP `404` means an unknown logical reference. `410` means expired evidence. Both degrade evidence only, not the Event.
The payload forbids arbitrary properties, so filesystem paths, camera credentials, bearer/user tokens, signed URLs, face templates and notification/Alert state fail schema validation. Do not add access URLs to v1. A short-lived download grant, if later required, needs a separately reviewed endpoint and security contract.
## Ownership
- Brain may request evidence but maps only logical metadata it actually knows.
- Sense is the default evidence owner and advances the status monotonically for a given capture attempt: `pending -> processing -> success|failed`. It must retain the same `evidence_id` while status changes.
- A relay transports the reference unchanged and must not resolve it into a path or URL.
- Bell stores the latest evidence metadata separately from its immutable Event. Evidence failure/expiry never changes Alert ack/close state.
## Compatibility and rollback
v1 consumers ignore no unknown fields because the v1 schema is closed. Additive fields therefore require a new schema revision that producers enable only after consumers accept it. Changed meaning, removed fields, or new required fields require `/v2`. Rollback disables the new producer and continues resolving stored v1 references; it never deletes Event, Receipt, Outbox, or evidence audit facts.
Run the standalone contract check from the repository root:
```powershell
python contracts/tests/evidence-v1/test_contract.py
```
@@ -1,60 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://yovision.local/contracts/evidence/v1/evidence-reference.schema.json",
"title": "YoVision evidence logical reference v1",
"type": "object",
"additionalProperties": false,
"required": [
"schema_version",
"evidence_id",
"owner_id",
"type",
"status",
"captured_at",
"status_updated_at"
],
"properties": {
"schema_version": {"const": "yovision.evidence-reference/v1"},
"evidence_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
"owner_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
"type": {"enum": ["snapshot", "clip"]},
"status": {"enum": ["pending", "processing", "success", "failed"]},
"captured_at": {"type": "string", "format": "date-time"},
"status_updated_at": {"type": "string", "format": "date-time"},
"expires_at": {"type": "string", "format": "date-time"},
"content_type": {"enum": ["image/jpeg", "image/png", "video/mp4"]},
"integrity": {
"type": "object",
"additionalProperties": false,
"required": ["algorithm", "digest", "size_bytes"],
"properties": {
"algorithm": {"const": "sha256"},
"digest": {"type": "string", "pattern": "^[a-f0-9]{64}$"},
"size_bytes": {"type": "integer", "minimum": 0}
}
},
"failure": {
"type": "object",
"additionalProperties": false,
"required": ["code", "retryable"],
"properties": {
"code": {"enum": ["capture_failed", "processing_failed", "expired", "unavailable"]},
"retryable": {"type": "boolean"}
}
}
},
"allOf": [
{
"if": {"properties": {"status": {"const": "success"}}, "required": ["status"]},
"then": {"required": ["content_type", "integrity"], "not": {"required": ["failure"]}}
},
{
"if": {"properties": {"status": {"const": "failed"}}, "required": ["status"]},
"then": {"required": ["failure"], "not": {"anyOf": [{"required": ["content_type"]}, {"required": ["integrity"]}]}}
},
{
"if": {"properties": {"status": {"enum": ["pending", "processing"]}}, "required": ["status"]},
"then": {"not": {"anyOf": [{"required": ["content_type"]}, {"required": ["integrity"]}, {"required": ["failure"]}]}}
}
]
}
@@ -1,10 +0,0 @@
{
"schema_version": "yovision.evidence-reference/v1",
"evidence_id": "ev-school-east-0002",
"owner_id": "sense-school-a",
"type": "clip",
"status": "failed",
"captured_at": "2026-08-31T00:03:10.000Z",
"status_updated_at": "2026-08-31T00:03:13.100Z",
"failure": {"code": "processing_failed", "retryable": true}
}
@@ -1,9 +0,0 @@
{
"schema_version": "yovision.evidence-reference/v1",
"evidence_id": "ev-school-east-0001",
"owner_id": "sense-school-a",
"type": "snapshot",
"status": "pending",
"captured_at": "2026-08-31T00:00:01.125Z",
"status_updated_at": "2026-08-31T00:00:01.125Z"
}
@@ -1,16 +0,0 @@
{
"schema_version": "yovision.evidence-reference/v1",
"evidence_id": "ev-school-east-0001",
"owner_id": "sense-school-a",
"type": "snapshot",
"status": "success",
"captured_at": "2026-08-31T00:00:01.125Z",
"status_updated_at": "2026-08-31T00:00:02.450Z",
"expires_at": "2026-09-07T00:00:01.125Z",
"content_type": "image/jpeg",
"integrity": {
"algorithm": "sha256",
"digest": "2f77668a9dfbf8d5848b9e6d7da867800b7b6790625f16b45a101f1ca1f7da75",
"size_bytes": 48215
}
}
-19
View File
@@ -1,19 +0,0 @@
{
"openapi": "3.1.0",
"info": {"title": "YoVision evidence reference API", "version": "1.0.0"},
"paths": {
"/v1/evidence/{evidence_id}": {
"get": {
"summary": "Resolve current metadata for a logical evidence reference",
"parameters": [
{"name": "evidence_id", "in": "path", "required": true, "schema": {"type": "string"}}
],
"responses": {
"200": {"description": "Current metadata, including pending, processing, success or failed states", "content": {"application/json": {"schema": {"$ref": "./evidence-reference.schema.json"}}}},
"404": {"description": "Unknown logical reference", "content": {"application/problem+json": {"schema": {"$ref": "../../events/v1/problem.schema.json"}}}},
"410": {"description": "Evidence expired; event remains valid", "content": {"application/problem+json": {"schema": {"$ref": "../../events/v1/problem.schema.json"}}}}
}
}
}
}
}
+47
View File
@@ -0,0 +1,47 @@
# Brain → Sense 运行与健康状态契约 v1
本目录是 Brain 运行状态到 Sense 运维投影的版本化事实源。Brain 只发布脱敏状态事实;Sense 不读取 Brain 的缓存、数据库或内部运行对象,也不能借此契约执行远程命令。
## 消息与时间语义
- `schema_version` 固定为 `yovision.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
`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,且必须带稳定错误码。
## 兼容与回退
- v1 字段语义冻结,未知字段被拒绝。新增可选字段或错误码前必须更新本契约及双方测试;改变字段语义或删除字段发布新主版本。
- 消费者必须按 `schema_version` 先分派到对应版本验证器。未知主版本停止摄取并记录 `UNSUPPORTED_SCHEMA_VERSION`,不得清空或覆盖最后已知投影。
- 回退时 Sense 停止摄取新版本,继续使用上一冻结版本的 adapter 和最后已知投影。回退不触发 Brain 重启或运行态修改。
## 安全边界
只允许 Schema 列出的字段。逻辑引用不允许 `/` 或 `\\`,因此不能携带绝对路径。消息不得包含凭据/token、堆栈、内部路径、用户会话、客户视频/图像、人脸信息或业务 Alert。结构化错误只传稳定错误码,不传自由文本错误详情。
错误码、映射责任和可复制验证分别见 `error-codes.md`、`mapping.md` 与 `../../tests/runtime-status-v1/README.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 发布。
@@ -0,0 +1,15 @@
{
"schema_version": "yovision.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" },
"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" }
}
@@ -0,0 +1,15 @@
{
"schema_version": "yovision.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" },
"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"
}
@@ -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": []
}
@@ -0,0 +1,14 @@
{
"schema_version": "yovision.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" },
"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": []
}
@@ -0,0 +1,14 @@
{
"schema_version": "yovision.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" },
"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": []
}
@@ -0,0 +1,15 @@
{
"schema_version": "yovision.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" },
"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" }
}
@@ -0,0 +1,18 @@
{
"schema_version": "yovision.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" },
"configurations": [
{ "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 20, "error_code": null }
],
"health": {
"overall": "healthy",
"error_codes": [],
"metrics": { "load_percent": 40.0, "queue_depth": 0, "latency_ms": 22.0 }
},
"inputs": []
}
@@ -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": []
}
@@ -0,0 +1,26 @@
{
"schema_version": "yovision.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" },
"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"],
"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 }
}
]
}
@@ -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": []
}
@@ -0,0 +1,18 @@
{
"schema_version": "yovision.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" },
"configurations": [
{ "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 21, "error_code": null }
],
"health": {
"overall": "degraded",
"error_codes": ["INPUT_UNREACHABLE"],
"metrics": { "load_percent": 30.0, "queue_depth": 1, "latency_ms": 30.0 }
},
"inputs": []
}
@@ -0,0 +1,18 @@
{
"schema_version": "yovision.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" },
"configurations": [
{ "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 21, "error_code": null }
],
"health": {
"overall": "healthy",
"error_codes": [],
"metrics": { "load_percent": 36.0, "queue_depth": 0, "latency_ms": 20.0 }
},
"inputs": []
}
@@ -0,0 +1,25 @@
{
"schema_version": "yovision.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" },
"configurations": [
{ "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 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 }
}
]
}
+20
View File
@@ -0,0 +1,20 @@
# Brain → Sense mapper 字段责任
| 契约字段 | Brain 生产者责任 | Sense 消费者投影责任 |
|---|---|---|
| `schema_version` | 固定发布 `yovision.runtime-status/v1` | 先按主版本分派;未知版本不覆盖最后投影 |
| `status_id` | 每次观测生成唯一幂等键 | 去重,不把重复消息当成新观测 |
| `brain_instance_ref` | 发布部署时分配的逻辑引用 | 映射到内部 edge node;不把它当数据库主键 |
| `sequence` | 同实例单调递增 | 拒绝倒序消息,保留最后已知投影 |
| `observed_at` | 发布观测完成时间 | 校验未来偏差;用它推导 fresh/stale,不用接收时间覆盖 |
| `runtime.*` | 报告真实运行状态和脱敏版本 | 校验迁移并形成只读运维状态 |
| `model.*` | 报告逻辑模型引用及版本,不报告文件路径 | 显示版本差异,不推导模型下载或重启命令 |
| `configurations[]` | 每个 `config_id` 报告真实应用结果和 integer revision;同一消息内 ID 唯一 | 按 `config_id` 与 Sense 内部期望 revision 比较;拒绝重复 ID,不回写 Brain 状态 |
| `health.*` | 聚合无敏感健康与有界指标 | 展示健康、指标和稳定错误码,不生成业务 Alert |
| `inputs[]` | 按逻辑输入发布安全摘要 | 按 `input_ref` 映射运维投影,不读取视频或检测内容 |
## 契约测试责任
- Brain:对所有发布消息执行 Schema 校验;覆盖各运行状态、配置应用结果、降级/失败以及敏感字段拒绝。
- Sense:使用同一有效/无效样例;覆盖版本分派、幂等与倒序、30 秒未来偏差、90 秒陈旧边界、状态迁移、offline/recovery、revision mismatch 及回退不覆盖最后投影。
- 协调契约:`contracts/tests/runtime-status-v1/test_contract.py` 是双方最小共同测试。产品 adapter 仍需在各自工单中增加本地模型映射测试。
@@ -0,0 +1,172 @@
{
"$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",
"configurations",
"health",
"inputs"
],
"properties": {
"schema_version": { "const": "yovision.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" }
}
},
"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"]
},
"applied_revision": {
"type": ["integer", "null"],
"minimum": 1
},
"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": "integer", "minimum": 1 } } }
},
{
"if": {
"required": ["apply_state"],
"properties": { "apply_state": { "const": "rejected" } }
},
"then": { "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": {
"configId": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[A-Za-z0-9][A-Za-z0-9._~-]*$"
},
"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 }
}
}
}
}
@@ -1,91 +0,0 @@
"""Small dependency-free validator for the JSON Schema keywords used by v1 contracts."""
from __future__ import annotations
import json
import math
import re
from datetime import datetime
from pathlib import Path
from typing import Any
def load_json(path: Path) -> Any:
return json.loads(path.read_text(encoding="utf-8"))
def canonical_bytes(value: Any) -> bytes:
"""Canonical bytes for checked-in JCS vectors (all vector numbers are JCS-safe)."""
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8")
def validate(instance: Any, schema: dict[str, Any], schema_path: Path, location: str = "$") -> list[str]:
if "$ref" in schema:
ref = schema["$ref"]
if ref.startswith("#"):
return [f"{location}: local fragments are not supported by the contract checker"]
target = (schema_path.parent / ref).resolve()
return validate(instance, load_json(target), target, location)
errors: list[str] = []
for subschema in schema.get("allOf", []):
errors.extend(validate(instance, subschema, schema_path, location))
if "anyOf" in schema and not any(not validate(instance, item, schema_path, location) for item in schema["anyOf"]):
errors.append(f"{location}: does not match anyOf")
if "not" in schema and not validate(instance, schema["not"], schema_path, location):
errors.append(f"{location}: matches forbidden schema")
if "if" in schema and not validate(instance, schema["if"], schema_path, location):
errors.extend(validate(instance, schema.get("then", {}), schema_path, location))
expected = schema.get("type")
type_ok = {
"object": lambda x: isinstance(x, dict),
"array": lambda x: isinstance(x, list),
"string": lambda x: isinstance(x, str),
"integer": lambda x: isinstance(x, int) and not isinstance(x, bool),
"number": lambda x: isinstance(x, (int, float)) and not isinstance(x, bool) and math.isfinite(x),
"boolean": lambda x: isinstance(x, bool),
}
if expected and (expected not in type_ok or not type_ok[expected](instance)):
return errors + [f"{location}: expected {expected}"]
if "const" in schema and instance != schema["const"]:
errors.append(f"{location}: expected constant {schema['const']!r}")
if "enum" in schema and instance not in schema["enum"]:
errors.append(f"{location}: value not in enum")
if isinstance(instance, dict):
required = schema.get("required", [])
errors.extend(f"{location}: missing {name}" for name in required if name not in instance)
properties = schema.get("properties", {})
if schema.get("additionalProperties") is False:
errors.extend(f"{location}: unknown property {name}" for name in instance if name not in properties)
for name, value in instance.items():
if name in properties:
errors.extend(validate(value, properties[name], schema_path, f"{location}.{name}"))
elif isinstance(instance, list):
if len(instance) < schema.get("minItems", 0):
errors.append(f"{location}: too few items")
if "maxItems" in schema and len(instance) > schema["maxItems"]:
errors.append(f"{location}: too many items")
if schema.get("uniqueItems") and len({canonical_bytes(item) for item in instance}) != len(instance):
errors.append(f"{location}: duplicate items")
for index, value in enumerate(instance):
errors.extend(validate(value, schema.get("items", {}), schema_path, f"{location}[{index}]"))
elif isinstance(instance, str):
if len(instance) < schema.get("minLength", 0):
errors.append(f"{location}: string too short")
if "maxLength" in schema and len(instance) > schema["maxLength"]:
errors.append(f"{location}: string too long")
if "pattern" in schema and re.fullmatch(schema["pattern"], instance) is None:
errors.append(f"{location}: pattern mismatch")
if schema.get("format") == "date-time":
try:
datetime.fromisoformat(instance.replace("Z", "+00:00"))
except ValueError:
errors.append(f"{location}: invalid date-time")
elif isinstance(instance, (int, float)) and not isinstance(instance, bool):
if "minimum" in schema and instance < schema["minimum"]:
errors.append(f"{location}: below minimum")
if "maximum" in schema and instance > schema["maximum"]:
errors.append(f"{location}: above maximum")
return errors
@@ -1,17 +0,0 @@
{
"schema_version": "yovision.event/v1",
"producer_id": "brain-school-a",
"source_event_id": "evt-sensitive-0001",
"site_ref": "site-school-a",
"device_ref": "camera-east-gate",
"profile_ref": "profile-main-stream",
"event_type": "dangerous_area_entered",
"occurred_at": "2026-08-31T00:00:01.125Z",
"severity": "high",
"rule": {"rule_id": "rule-east-danger", "version": "3"},
"model": {"name": "anonymous-detector", "version": "2026.08"},
"observation": {"track_id": "track-0042", "category": "person", "confidence": 0.93, "face_feature": "forbidden"},
"region": {"region_id": "region-east-danger", "kind": "area"},
"evidence": [],
"camera_password": "forbidden"
}
@@ -1,16 +0,0 @@
{
"schema_version": "yovision.event/v2",
"producer_id": "brain-school-a",
"source_event_id": "evt-unknown-version-0001",
"site_ref": "site-school-a",
"device_ref": "camera-east-gate",
"profile_ref": "profile-main-stream",
"event_type": "dangerous_area_entered",
"occurred_at": "2026-08-31T00:00:01.125Z",
"severity": "high",
"rule": {"rule_id": "rule-east-danger", "version": "3"},
"model": {"name": "anonymous-detector", "version": "2026.08"},
"observation": {"track_id": "track-0042", "category": "person", "confidence": 0.93},
"region": {"region_id": "region-east-danger", "kind": "area"},
"evidence": []
}
@@ -1,13 +0,0 @@
{
"algorithm": "RFC8785-JCS+SHA-256",
"vectors": [
{
"name": "dangerous-area-original-and-reordered-duplicate",
"fixture": "../../events/v1/examples/dangerous-area.json",
"idempotency_key": ["brain-school-a", "evt-area-20260831-0001"],
"payload_sha256": "4cc1e93820195caf713ea675ff33f178c9d4997dd8a81cb61287e9fea0e3d5e1",
"conflict_patch": {"severity": "critical"},
"conflict_payload_sha256": "7076771f7827d97ef45831ae221046b8cb347f152dd222c2edd6f14a58e173b2"
}
]
}
-110
View File
@@ -1,110 +0,0 @@
from __future__ import annotations
import copy
import hashlib
import sys
import unittest
from pathlib import Path
HERE = Path(__file__).resolve().parent
CONTRACTS = HERE.parents[1]
sys.path.insert(0, str(HERE))
from contract_support import canonical_bytes, load_json, validate # noqa: E402
class EventV1ContractTests(unittest.TestCase):
schema_path = CONTRACTS / "events" / "v1" / "event.schema.json"
schema = load_json(schema_path)
def assert_valid(self, payload: object) -> None:
self.assertEqual([], validate(payload, self.schema, self.schema_path))
def test_anonymous_area_and_line_examples_are_valid(self) -> None:
for name in ("dangerous-area.json", "directional-line-crossed.json"):
with self.subTest(name=name):
self.assert_valid(load_json(CONTRACTS / "events" / "v1" / "examples" / name))
def test_idempotency_vector_duplicate_and_conflict(self) -> None:
vectors = load_json(HERE / "idempotency-vectors.json")["vectors"]
for vector in vectors:
payload = load_json((HERE / vector["fixture"]).resolve())
self.assertEqual(vector["idempotency_key"], [payload["producer_id"], payload["source_event_id"]])
digest = hashlib.sha256(canonical_bytes(payload)).hexdigest()
self.assertEqual(vector["payload_sha256"], digest)
reordered = dict(reversed(list(payload.items())))
self.assertEqual(digest, hashlib.sha256(canonical_bytes(reordered)).hexdigest())
conflict = copy.deepcopy(payload)
conflict.update(vector["conflict_patch"])
conflict_digest = hashlib.sha256(canonical_bytes(conflict)).hexdigest()
self.assertEqual(vector["conflict_payload_sha256"], conflict_digest)
self.assertNotEqual(digest, conflict_digest)
def test_unknown_version_is_rejected(self) -> None:
payload = load_json(HERE / "fixtures" / "unknown-version.json")
self.assertTrue(validate(payload, self.schema, self.schema_path))
def test_brain_producer_sense_relay_and_bell_consumer_fixture(self) -> None:
produced = load_json(CONTRACTS / "events" / "v1" / "examples" / "dangerous-area.json")
self.assert_valid(produced)
relayed = copy.deepcopy(produced)
self.assertEqual(
(produced["producer_id"], produced["source_event_id"]),
(relayed["producer_id"], relayed["source_event_id"]),
)
self.assertEqual(canonical_bytes(produced), canonical_bytes(relayed))
bell_allowed = set(self.schema["properties"])
self.assertEqual(set(produced), bell_allowed)
self.assertNotIn("alert", produced)
self.assertNotIn("receipt", produced)
def test_sensitive_and_internal_fields_are_rejected(self) -> None:
payload = load_json(HERE / "fixtures" / "sensitive-field.json")
errors = validate(payload, self.schema, self.schema_path)
self.assertTrue(any("camera_password" in error for error in errors))
self.assertTrue(any("face_feature" in error for error in errors))
base = load_json(CONTRACTS / "events" / "v1" / "examples" / "dangerous-area.json")
for forbidden, value in {
"user_token": "forbidden", "ack_state": "acked", "local_path": "C:/forbidden",
"signed_url": "https://forbidden.invalid/object?signature=forbidden"
}.items():
with self.subTest(forbidden=forbidden):
candidate = copy.deepcopy(base)
candidate[forbidden] = value
self.assertTrue(validate(candidate, self.schema, self.schema_path))
def test_openapi_references_exist_and_responses_are_explicit(self) -> None:
path = CONTRACTS / "events" / "v1" / "openapi.json"
spec = load_json(path)
operation = spec["paths"]["/v1/events"]["post"]
self.assertEqual({"200", "201", "400", "409", "422"}, set(operation["responses"]))
refs: list[str] = []
def collect(value: object) -> None:
if isinstance(value, dict):
refs.extend(item for key, item in value.items() if key == "$ref")
for item in value.values(): collect(item)
elif isinstance(value, list):
for item in value: collect(item)
collect(spec)
self.assertTrue(refs)
for ref in refs:
self.assertTrue((path.parent / ref).resolve().is_file(), ref)
def test_duplicate_conflict_and_unknown_version_response_examples(self) -> None:
directory = CONTRACTS / "events" / "v1"
cases = (
("duplicate-result.json", "ingest-result.schema.json"),
("idempotency-conflict-problem.json", "problem.schema.json"),
("unsupported-version-problem.json", "problem.schema.json"),
)
for fixture_name, schema_name in cases:
with self.subTest(fixture=fixture_name):
schema_path = directory / schema_name
errors = validate(load_json(directory / "examples" / fixture_name), load_json(schema_path), schema_path)
self.assertEqual([], errors)
if __name__ == "__main__":
unittest.main(verbosity=2)
@@ -1,12 +0,0 @@
{
"schema_version": "yovision.evidence-reference/v1",
"evidence_id": "ev-sensitive-0001",
"owner_id": "sense-school-a",
"type": "snapshot",
"status": "success",
"captured_at": "2026-08-31T00:00:01.125Z",
"status_updated_at": "2026-08-31T00:00:02.450Z",
"content_type": "image/jpeg",
"integrity": {"algorithm": "sha256", "digest": "2f77668a9dfbf8d5848b9e6d7da867800b7b6790625f16b45a101f1ca1f7da75", "size_bytes": 48215},
"local_path": "forbidden"
}
@@ -1,88 +0,0 @@
from __future__ import annotations
import copy
import sys
import unittest
from pathlib import Path
HERE = Path(__file__).resolve().parent
CONTRACTS = HERE.parents[1]
EVENT_SUPPORT = HERE.parent / "events-v1"
sys.path.insert(0, str(EVENT_SUPPORT))
from contract_support import load_json, validate # noqa: E402
class EvidenceV1ContractTests(unittest.TestCase):
schema_path = CONTRACTS / "evidence" / "v1" / "evidence-reference.schema.json"
schema = load_json(schema_path)
def errors_for(self, payload: object) -> list[str]:
return validate(payload, self.schema, self.schema_path)
def test_pending_success_and_failed_examples_are_valid(self) -> None:
for name in ("pending.json", "success.json", "failed.json"):
with self.subTest(name=name):
payload = load_json(CONTRACTS / "evidence" / "v1" / "examples" / name)
self.assertEqual([], self.errors_for(payload))
def test_state_specific_metadata_is_enforced(self) -> None:
success = load_json(CONTRACTS / "evidence" / "v1" / "examples" / "success.json")
for required in ("content_type", "integrity"):
with self.subTest(success_requires=required):
candidate = copy.deepcopy(success)
del candidate[required]
self.assertTrue(self.errors_for(candidate))
legacy_available = copy.deepcopy(success)
legacy_available["status"] = "available"
self.assertTrue(self.errors_for(legacy_available))
failed = load_json(CONTRACTS / "evidence" / "v1" / "examples" / "failed.json")
del failed["failure"]
self.assertTrue(self.errors_for(failed))
pending = load_json(CONTRACTS / "evidence" / "v1" / "examples" / "pending.json")
pending["content_type"] = "image/jpeg"
self.assertTrue(self.errors_for(pending))
def test_sensitive_access_material_and_unknown_version_are_rejected(self) -> None:
fixture = load_json(HERE / "fixtures" / "sensitive-field.json")
self.assertTrue(any("local_path" in error for error in self.errors_for(fixture)))
base = load_json(CONTRACTS / "evidence" / "v1" / "examples" / "pending.json")
for forbidden, value in {
"camera_password": "forbidden",
"user_token": "forbidden",
"signed_url": "https://forbidden.invalid/object?signature=forbidden",
"face_feature": "forbidden",
"alert_state": "acked"
}.items():
with self.subTest(forbidden=forbidden):
candidate = copy.deepcopy(base)
candidate[forbidden] = value
self.assertTrue(self.errors_for(candidate))
unknown = copy.deepcopy(base)
unknown["schema_version"] = "yovision.evidence-reference/v2"
self.assertTrue(self.errors_for(unknown))
def test_openapi_refs_and_degradation_responses(self) -> None:
path = CONTRACTS / "evidence" / "v1" / "openapi.json"
spec = load_json(path)
operation = spec["paths"]["/v1/evidence/{evidence_id}"]["get"]
self.assertEqual({"200", "404", "410"}, set(operation["responses"]))
refs: list[str] = []
def collect(value: object) -> None:
if isinstance(value, dict):
refs.extend(item for key, item in value.items() if key == "$ref")
for item in value.values(): collect(item)
elif isinstance(value, list):
for item in value: collect(item)
collect(spec)
for ref in refs:
self.assertTrue((path.parent / ref).resolve().is_file(), ref)
if __name__ == "__main__":
unittest.main(verbosity=2)
@@ -0,0 +1,9 @@
# yovision.runtime-status/v1 契约测试
从仓库根目录运行:
```powershell
python -m unittest discover -s contracts/tests/runtime-status-v1 -p "test_*.py" -v
```
测试只使用 Python 标准库,不安装依赖、不访问网络。它对冻结 Schema 的已用关键字执行验证,并覆盖状态迁移、时间/陈旧边界、offline/recovery、空/多配置流、四种配置应用状态、重复 `config_id`、integer revision mismatch、未知主版本、倒序消息、回退保留和敏感字段拒绝。产品 adapter 还需在各自工单中运行本地模型映射测试。
@@ -0,0 +1,318 @@
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 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):
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"] != "yovision.runtime-status/v1":
return False
if validate_contract(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"], "yovision.runtime-status/v1")
self.assertFalse(SCHEMA["additionalProperties"])
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), 7)
for path in paths:
with self.subTest(path=path.name):
self.assertEqual(validate_contract(load(path)), [])
def test_all_invalid_examples_are_rejected(self):
paths = sorted(INVALID_DIR.glob("*.json"))
self.assertGreaterEqual(len(paths), 6)
for path in paths:
with self.subTest(path=path.name):
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_contract(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))
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_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):
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 valid_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(apply_state=apply_state):
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_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_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.*", "configurations[]", "health.*", "inputs[]"):
self.assertIn(f"`{field}`", mapping)
self.assertIn("Brain", mapping)
self.assertIn("Sense", mapping)
if __name__ == "__main__":
unittest.main()