feat: implement T-019 reliable event ingress
Harness governance / validate (pull_request) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Sense 控制面、准入投影与本地审计契约 v1
|
||||
# YoVision 控制面、审计与内部事件传输契约 v1
|
||||
|
||||
> 冻结日期:2026-08-11。Control API 与审计 relay 契约版本:`1.0.0`。Sense 是设备期望态的提供方;Bell 是 Tenant、Site、Area、RBAC、配额与全局审计的所有者。T-016 已实现 Sense Outbox 到 Bell 的内部 relay;Bell 公共管理服务与 JWT/OIDC 仍未实现。
|
||||
> 冻结日期:2026-08-11。Control API、审计 relay 与 Brain 事件 ingress 契约版本:`1.0.0`。Sense 是设备期望态的提供方;Bell 是 Tenant、Site、Area、RBAC、配额、事件与全局审计的所有者。T-016 已实现 Sense Outbox 审计 relay,T-019 已实现 Brain 事件 Outbox ingress;Bell 公共管理服务与 JWT/OIDC 仍未实现。
|
||||
|
||||
## 契约文件
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
| [`sense-device-audit-v1.schema.json`](sense-device-audit-v1.schema.json) | Sense | 本地 Outbox;Bell relay | 脱敏设备操作审计事实,不包含传输协议 |
|
||||
| [`sense-device-audit-v2.schema.json`](sense-device-audit-v2.schema.json) | Sense | 本地 Outbox;Bell relay | v1 后继,增加脱敏配置修改受理事实;v1 文件保持不变 |
|
||||
| [`sense-audit-relay-v1.openapi.json`](sense-audit-relay-v1.openapi.json) | Bell | Sense | 内部批量端点、HMAC、逐项确认、nonce 防重与重试边界 |
|
||||
| [`brain-event-ingress-v1.openapi.json`](brain-event-ingress-v1.openapi.json) | Bell | Brain | 单业务事件入站、producer/key 绑定、Bell ID 与跨重启幂等语义 |
|
||||
|
||||
OpenAPI 的 `/api/v1` 路径是公共控制面边界;`/healthz`、`/readyz` 仍是非业务运维探针。v1 不提供设备删除:停用设备使用期望态接口,保留设备、操作和审计历史。Site、Area、配额、RBAC 和审计聚合不由 Sense 提供 CRUD。
|
||||
|
||||
@@ -72,6 +73,20 @@ Outbox 用 30 秒数据库时钟 lease、单调 fencing token 和 `FOR UPDATE SK
|
||||
{"version":1,"keys":[{"key_id":"sense-a","secret_base64url":"<external-secret>"}]}
|
||||
```
|
||||
|
||||
## Brain → Bell 业务事件 ingress
|
||||
|
||||
Brain 向 `POST /internal/v1/event-candidates` 单条投递完整 event v0.1 candidate;candidate 与冻结最终事件的顶层字段完全一致,只省略 Bell 所有的 `id`。envelope 额外携带 `schema_version=1` 和 `producer_id`。Bell 校验 HMAC、key 与 producer 的绑定、当前逻辑设备/Area 映射、event schema 和六项语义断言后生成 `evt_` ULID;首次接受返回 `201 accepted`,同一来源事实重投返回 `200 duplicate` 和原 Bell ID。
|
||||
|
||||
认证 canonical string 与审计 relay 使用相同五行算法,但 key 文件独立且每项增加 `producer_id`,不得混用审计 key。正文最多 1 MiB、deadline 10 秒、允许 300 秒时钟偏差,nonce 收据至少保留 600 秒;HTTP 只允许回环地址,非回环必须 HTTPS。key 文件形态为:
|
||||
|
||||
```json
|
||||
{"version":1,"keys":[{"key_id":"brain-a","producer_id":"brain-main","secret_base64url":"<external-secret>"}]}
|
||||
```
|
||||
|
||||
Bell 的永久 `event_ingress_receipts` 以 `(producer_id, source_event_id)` 唯一并保存 canonical candidate SHA-256。相同 hash 重投返回原 event ID,不同 hash 返回 `409 source_event_conflict`;事件、来源收据和成功 nonce 响应在一个 PostgreSQL 事务中提交。`event_ingress_nonces` 只是 10 分钟防重表,可清理;来源收据和事件不自动删除、不可更新。Brain 必须先把 candidate 写入仓库外 SQLite Outbox,只有 `accepted/duplicate` 可标记 delivered;401、网络和 5xx 以 1~300 秒退避重试,稳定 4xx 进入 dead letter,最多 100 次、最多 10,000 条待投递。
|
||||
|
||||
数字 `tenant_id/site_id/device_id` 通过 Bell 所有的 `event_ingress_bindings` 映射到当前 Bell Site/Area 和 Sense Device 逻辑 ID。运行时只读取 Sense 设备的 ID、Area 与 modality 列,不获得 endpoint、credential、profile token 或 path;绑定缺失/禁用、设备或 Area 不一致、删除、非视频或 `capture_policy != video_allowed` 均失败关闭。首版绑定只由受控 migration/admin SQL 配置,没有公共 CRUD。
|
||||
|
||||
## 兼容与废弃
|
||||
|
||||
- v1 可增加不改变已有语义的可选响应字段和新错误细节;客户端必须忽略未知响应字段。
|
||||
@@ -87,9 +102,11 @@ Outbox 用 30 秒数据库时钟 lease、单调 fencing token 和 `FOR UPDATE SK
|
||||
python -m json.tool docs/contracts/sense-control-v1.openapi.json | Out-Null
|
||||
python -m json.tool docs/contracts/sense-device-audit-v2.schema.json | Out-Null
|
||||
python -m json.tool docs/contracts/sense-audit-relay-v1.openapi.json | Out-Null
|
||||
python -m json.tool docs/contracts/brain-event-ingress-v1.openapi.json | Out-Null
|
||||
python -m unittest discover -s tests -p "test_sense_control_contract.py"
|
||||
python -m unittest discover -s tests -p "test_sense_control_implementation.py"
|
||||
python -m unittest discover -s tests -p "test_sense_audit_relay_contract.py"
|
||||
python -m unittest discover -s tests -p "test_brain_event_ingress_contract.py"
|
||||
```
|
||||
|
||||
测试同时校验 OpenAPI 结构、生成 server glue、HTTP handler 与 PostgreSQL migration/事务;它不替代 Bell 消费方联合验收或客户现场容量验证。
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "YoVision Brain Event Ingress",
|
||||
"version": "1.0.0",
|
||||
"description": "Internal single-event transport. The candidate is the frozen event v0.1 object with only the Bell-owned id omitted."
|
||||
},
|
||||
"paths": {
|
||||
"/internal/v1/event-candidates": {
|
||||
"post": {
|
||||
"operationId": "ingestBrainEventCandidate",
|
||||
"summary": "Validate and persist one Brain event candidate",
|
||||
"parameters": [
|
||||
{"$ref": "#/components/parameters/KeyId"},
|
||||
{"$ref": "#/components/parameters/Timestamp"},
|
||||
{"$ref": "#/components/parameters/Nonce"},
|
||||
{"$ref": "#/components/parameters/Signature"}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {"$ref": "#/components/schemas/IngressRequest"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {"description": "The same producer/source candidate was already stored", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/IngressResponse"}}}},
|
||||
"201": {"description": "Candidate accepted and stored", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/IngressResponse"}}}},
|
||||
"400": {"$ref": "#/components/responses/Error"},
|
||||
"401": {"$ref": "#/components/responses/Error"},
|
||||
"403": {"$ref": "#/components/responses/Error"},
|
||||
"409": {"$ref": "#/components/responses/Error"},
|
||||
"413": {"$ref": "#/components/responses/Error"},
|
||||
"422": {"$ref": "#/components/responses/Error"},
|
||||
"503": {"$ref": "#/components/responses/Error"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"parameters": {
|
||||
"KeyId": {"name": "X-YoVision-Key-Id", "in": "header", "required": true, "schema": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$"}},
|
||||
"Timestamp": {"name": "X-YoVision-Timestamp", "in": "header", "required": true, "schema": {"type": "string", "pattern": "^[0-9]{10,}$"}},
|
||||
"Nonce": {"name": "X-YoVision-Nonce", "in": "header", "required": true, "schema": {"type": "string", "minLength": 22, "maxLength": 64}},
|
||||
"Signature": {"name": "X-YoVision-Signature", "in": "header", "required": true, "schema": {"type": "string", "minLength": 43, "maxLength": 43}}
|
||||
},
|
||||
"schemas": {
|
||||
"IngressRequest": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["schema_version", "producer_id", "candidate"],
|
||||
"properties": {
|
||||
"schema_version": {"const": 1},
|
||||
"producer_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$"},
|
||||
"candidate": {"$ref": "#/components/schemas/EventV01Candidate"}
|
||||
}
|
||||
},
|
||||
"EventV01Candidate": {
|
||||
"description": "Exact docs/raw/contracts/event-v0.1.schema.json event object with the required Bell-owned id field removed; nested shapes and semantic assertions remain authoritative in that frozen contract.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schema_version", "source_event_id", "tenant_id", "site_id", "device_id", "sensors",
|
||||
"kind", "severity", "confidence", "occurred_at", "detected_at", "latency_seconds",
|
||||
"config_version", "rule", "subject", "observation", "evidence", "dedup_key",
|
||||
"aggregated_into", "outcome", "outcome_source", "outcome_reason", "diagnostics", "ext"
|
||||
],
|
||||
"properties": {
|
||||
"schema_version": {"const": "0.1"},
|
||||
"source_event_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,128}$"},
|
||||
"tenant_id": {"type": "integer", "minimum": 1},
|
||||
"site_id": {"type": "integer", "minimum": 1},
|
||||
"device_id": {"type": "integer", "minimum": 1},
|
||||
"sensors": {"type": "array", "minItems": 1, "items": {"type": "object"}},
|
||||
"kind": {"type": "string", "pattern": "^[a-z][a-z0-9_]{2,63}$"},
|
||||
"severity": {"type": "string", "enum": ["low", "medium", "high", "critical"]},
|
||||
"confidence": {"type": ["number", "null"], "minimum": 0, "maximum": 1},
|
||||
"occurred_at": {"type": "string", "format": "date-time"},
|
||||
"detected_at": {"type": "string", "format": "date-time"},
|
||||
"latency_seconds": {"type": "number", "minimum": 0},
|
||||
"config_version": {"type": "string", "minLength": 1, "maxLength": 128},
|
||||
"rule": {"type": ["object", "null"]},
|
||||
"subject": {"type": "object"},
|
||||
"observation": {"type": ["object", "null"]},
|
||||
"evidence": {"type": "object"},
|
||||
"dedup_key": {"type": ["string", "null"]},
|
||||
"aggregated_into": {"type": ["string", "null"]},
|
||||
"outcome": {"type": "string"},
|
||||
"outcome_source": {"type": ["string", "null"]},
|
||||
"outcome_reason": {"type": ["string", "null"]},
|
||||
"diagnostics": {"type": ["object", "null"]},
|
||||
"ext": {"type": "object"}
|
||||
}
|
||||
},
|
||||
"IngressResponse": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["schema_version", "producer_id", "source_event_id", "event_id", "status"],
|
||||
"properties": {
|
||||
"schema_version": {"const": 1},
|
||||
"producer_id": {"type": "string"},
|
||||
"source_event_id": {"type": "string"},
|
||||
"event_id": {"type": "string", "pattern": "^evt_[0-9A-HJKMNP-TV-Z]{26}$"},
|
||||
"status": {"type": "string", "enum": ["accepted", "duplicate"]}
|
||||
}
|
||||
},
|
||||
"Error": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["error"],
|
||||
"properties": {
|
||||
"error": {"type": "string"},
|
||||
"detail_code": {"type": "string"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"Error": {"description": "Stable machine-readable failure", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}
|
||||
}
|
||||
},
|
||||
"x-yovision-final-event-schema": "docs/raw/contracts/event-v0.1.schema.json",
|
||||
"x-yovision-signature": {
|
||||
"algorithm": "HMAC-SHA256",
|
||||
"canonical_lines": ["method", "escaped_path", "unix_timestamp_seconds", "base64url_nonce", "lowercase_hex_sha256_body"],
|
||||
"clock_skew_seconds": 300,
|
||||
"nonce_receipt_ttl_seconds": 600,
|
||||
"minimum_secret_bytes": 32,
|
||||
"key_document_shape": {"version": 1, "keys": [{"key_id": "brain-a", "producer_id": "brain-main", "secret_base64url": "external-secret"}]}
|
||||
},
|
||||
"x-yovision-delivery": {
|
||||
"maximum_body_bytes": 1048576,
|
||||
"deadline_seconds": 10,
|
||||
"initial_retry_seconds": 1,
|
||||
"maximum_retry_seconds": 300,
|
||||
"maximum_attempts": 100,
|
||||
"maximum_queued": 10000,
|
||||
"success_statuses": ["accepted", "duplicate"],
|
||||
"retryable_http": [401, 500, 502, 503, 504]
|
||||
},
|
||||
"x-yovision-idempotency": {
|
||||
"source_scope": ["producer_id", "source_event_id"],
|
||||
"same_candidate": "return duplicate with the original Bell event_id",
|
||||
"different_candidate": "409 source_event_conflict",
|
||||
"bell_owns_event_id": true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user