Compare commits

...
Author SHA1 Message Date
QiuSW b548b05874 docs: 记录标准事件与证据 v1 契约 (#150) 2026-08-31 10:04:39 +08:00
ila 23a85278cb Merge pull request '#158' from feature/150-event-evidence-contract into dev
冻结 Sense/Brain→Bell 标准事件与证据引用契约 v1(#150)。
2026-08-31 09:55:35 +08:00
ila 96777a948f Merge pull request '#160' from docs/148-149-contract-acceptance into dev
同步 #148、#149 冻结契约的 Wiki 核心镜像。
2026-08-31 09:41:48 +08:00
QiuSW c2b023c9fe docs: 记录冻结的 Sense Brain v1 契约 (#148 #149) 2026-08-31 09:41:33 +08:00
ila 4c35da9ef6 Merge pull request '#157' from feature/149-runtime-status-contract into dev
冻结 Brain→Sense 推理运行与健康状态契约 v1(#149)。
2026-08-31 09:34:58 +08:00
ila 30c43aa8d7 Merge pull request '#159' from feature/148-source-config-contract into dev
冻结 Sense→Brain 媒体源与区域规则配置契约 v1(#148)。
2026-08-31 09:34:43 +08:00
QiuSW 359c553452 fix: align evidence success state (#150) 2026-08-31 08:57:25 +08:00
QiuSW 2e61167500 fix: 统一源配置契约字段命名 (#148) 2026-08-31 08:53:22 +08:00
QiuSW 67391acb16 feat: 冻结源配置契约 v1 (#148) 2026-08-31 08:48:18 +08:00
QiuSW 2a395aa126 feat: freeze event evidence contract v1 (#150) 2026-08-31 08:45:57 +08:00
44 changed files with 1772 additions and 10 deletions
+58
View File
@@ -0,0 +1,58 @@
# 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
@@ -0,0 +1,77 @@
{
"$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"}}}}}
}
]
}
@@ -0,0 +1,26 @@
{
"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"
}
]
}
@@ -0,0 +1,27 @@
{
"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}
}
]
}
@@ -0,0 +1,7 @@
{
"event_id": "bell-event-00000042",
"producer_id": "brain-school-a",
"source_event_id": "evt-area-20260831-0001",
"disposition": "duplicate",
"payload_sha256": "4cc1e93820195caf713ea675ff33f178c9d4997dd8a81cb61287e9fea0e3d5e1"
}
@@ -0,0 +1,5 @@
{
"code": "idempotency_conflict",
"message": "idempotency key already belongs to another canonical payload",
"existing_event_id": "bell-event-00000042"
}
@@ -0,0 +1,5 @@
{
"code": "unsupported_schema_version",
"message": "schema_version yovision.event/v2 is not accepted",
"field": "schema_version"
}
@@ -0,0 +1,15 @@
{
"$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
@@ -0,0 +1,22 @@
{
"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
@@ -0,0 +1,14 @@
{
"$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
@@ -0,0 +1,30 @@
# 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
```
@@ -0,0 +1,60 @@
{
"$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"]}]}}
}
]
}
@@ -0,0 +1,10 @@
{
"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}
}
@@ -0,0 +1,9 @@
{
"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"
}
@@ -0,0 +1,16 @@
{
"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
@@ -0,0 +1,19 @@
{
"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"}}}}
}
}
}
}
}
+35
View File
@@ -0,0 +1,35 @@
# Sense → Brain 媒体源与区域规则配置契约 v1
`yovision.source-config/v1` 是 Sense 发布、Brain 消费的完整配置快照。它只携带稳定逻辑标识、无凭据媒体引用、Profile 规格、归一化规则及完整性摘要,不暴露 Sense 数据库模型或 Brain 内部配置模型。
本版本选择 JSON Schema,而不是 OpenAPI:快照可经文件、消息或后续 connector 传输,工单 #148 不定义 HTTP 端点。后续 connector 若提供 HTTP API,应引用本 Schema,不复制字段定义。
## 文件
- `source-config.schema.json`:Draft 2020-12 JSON Schema。
- `examples/valid/`:可接受的 active 与待重校准快照。
- `examples/invalid/`:必须安全拒绝的版本、秘密、路径、坐标和绑定错误。
- `compatibility.md`:版本、兼容周期、迁移和回退规则。
- `mapper-fields.md`:Sense 生产者与 Brain 消费者字段映射和测试责任。
## 消费规则
1. 先按 JSON Schema 校验,再执行跨字段语义校验。
2. `schema_version` 必须精确等于 `yovision.source-config/v1`;未知主版本不得降级猜测。
3. `rule_set.profile_binding` 必须与 `profile.id/width/height` 完全一致。
4. `rule_set.state != active` 时不得运行任何规则;`recalibration_required` 表示 Profile 规格变化后需重新标定。
5. `areas` 与 `directional_lines` 的 `id` 在同一快照内必须全局唯一;多边形必须非退化,线段起终点不得相同。
6. `effective_at` 不得早于 `published_at`。
7. `integrity.value` 是移除顶层 `integrity` 后,对 RFC 8785 JCS 规范化 JSON 字节计算的 SHA-256 小写十六进制摘要。生产消费者应使用合规 JCS 实现;仓库样例只使用 JCS 简单类型子集。
`media.ref` 是 connector 解析的无凭据不透明引用,固定以 `media:` 开头。它不能包含 URI authority、用户名、密码、查询参数、fragment、Windows 盘符或文件系统路径。RTSP 凭据交换与机器身份不属于本契约。
## 可复制验证
从仓库根目录运行:
```powershell
& contracts\tests\source-config-v1\run.ps1
```
脚本在系统临时目录创建隔离虚拟环境、安装固定版本的 Schema 校验器并运行测试,不修改产品目录。测试结束后会清理临时环境。
@@ -0,0 +1,29 @@
# v1 兼容、迁移与回退
## 兼容规则
- v1 发布后只允许在预留的顶层 `extensions` 对象中增加命名空间化、非秘密的可选扩展。消费者必须忽略自己不认识的扩展命名空间,但仍须拒绝当前 Schema 或语义规则标记为非法的输入;发布扩展时应同步生产者/消费者测试。v1 核心对象保持封闭,不能通过新增核心字段规避新主版本。
- 删除字段、把可选改为必填、收紧已发布取值范围,或改变字段类型、单位、坐标系、Profile 绑定、revision、状态及媒体引用语义,均为破坏性变化,必须发布新主版本目录和新的 `schema_version` 值。
- 未知主版本必须安全拒绝并保留最后一个已验证配置。不得把未知版本转换成 v1,也不得继续启用来自未知版本的规则。
- v1 的坐标始终是相对于 `profile.width × profile.height` 图像平面的 0–1 归一化坐标;原点在左上,x 向右、y 向下。该语义不得在 v1 内改变。
## revision 与生效
- `(config_id, revision)` 唯一标识一个不可变快照;同一 `config_id` 的新发布必须使用严格递增的 `revision`。
- 消费者仅在 Schema、语义和完整性均通过后,按 `effective_at` 原子切换整个快照。重复收到同一 revision 应幂等处理;更小 revision 应拒绝为陈旧配置。
- Profile ID、分辨率或编码变化时,生产者必须发布新 revision。已有几何尚未按新 Profile 校准时,必须设置 `rule_set.state = recalibration_required`;消费者不得启用其中规则。
- 新 revision 校验失败或未到生效时间时,消费者保留上一份已验证且仍有效的 active revision。
## 支持周期
- 发布新主版本后,Sense 生产者与 Brain 消费者至少并行支持上一主版本一个正式发布周期,且不少于 90 天;具体停止日期必须在新版本协调工单中冻结。
- 并行期内生产者按目标消费者能力选择版本,不得把两个主版本字段混在同一快照。
## 回退
1. 停止分发有问题的新主版本或新 revision。
2. 重新发布上一主版本的最后一个已验证快照;若仍为同一 `config_id`,必须使用该主版本下新的、更大 revision,不能覆盖历史 revision。
3. Brain 通过完整 Schema、语义和摘要校验后原子切回;切换前继续使用最后一个有效快照,或在没有有效快照时保持规则停用。
4. 记录失败版本和拒绝原因,但不得记录媒体凭据或完整客户配置。
样例 `examples/valid/recalibration-required.json` 展示 Profile 变化后的安全停用状态。回退不修改已发布 v1 字段语义,也不要求读取 Sense 数据库。
@@ -0,0 +1,13 @@
{
"schema_version": "yovision.source-config/v1",
"config_id": "school-east-entry-01",
"revision": 7,
"published_at": "2026-08-31T00:10:00Z",
"effective_at": "2026-08-31T00:15:00Z",
"site": {"id": "site-east"},
"logical_device": {"id": "entry-camera-01"},
"profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frame_rate": 25},
"media": {"ref": "media:site-east/entry-01/main", "transport": "rtsp"},
"rule_set": {"version": "entry-rules-7", "state": "active", "profile_binding": {"profile_id": "main-stream", "width": 1920, "height": 1080}, "areas": [{"id": "bad-area", "version": 1, "kind": "danger_area", "enabled": true, "points": [{"x": 0, "y": 0}, {"x": 1.2, "y": 0}, {"x": 0, "y": 1}]}], "directional_lines": []},
"integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"}
}
@@ -0,0 +1,13 @@
{
"schema_version": "yovision.source-config/v1",
"config_id": "school-east-entry-01",
"revision": 7,
"published_at": "2026-08-31T00:10:00Z",
"effective_at": "2026-08-31T00:15:00Z",
"site": {"id": "site-east"},
"logical_device": {"id": "entry-camera-01"},
"profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frame_rate": 25},
"media": {"ref": "media:site-east/entry-01/main", "transport": "rtsp", "password": null},
"rule_set": {"version": "entry-rules-7", "state": "active", "profile_binding": {"profile_id": "main-stream", "width": 1920, "height": 1080}, "areas": [], "directional_lines": []},
"integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"}
}
@@ -0,0 +1,8 @@
{
"coordinate-out-of-range.json": "schema",
"credential-field.json": "secret",
"internal-path.json": "internal path",
"profile-binding-mismatch.json": "profile binding",
"query-token.json": "secret",
"unknown-major-version.json": "unknown schema"
}
@@ -0,0 +1,13 @@
{
"schema_version": "yovision.source-config/v1",
"config_id": "school-east-entry-01",
"revision": 7,
"published_at": "2026-08-31T00:10:00Z",
"effective_at": "2026-08-31T00:15:00Z",
"site": {"id": "site-east"},
"logical_device": {"id": "entry-camera-01"},
"profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frame_rate": 25},
"media": {"ref": "C:\\customers\\school-east\\camera-01", "transport": "rtsp"},
"rule_set": {"version": "entry-rules-7", "state": "active", "profile_binding": {"profile_id": "main-stream", "width": 1920, "height": 1080}, "areas": [], "directional_lines": []},
"integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"}
}
@@ -0,0 +1,13 @@
{
"schema_version": "yovision.source-config/v1",
"config_id": "school-east-entry-01",
"revision": 7,
"published_at": "2026-08-31T00:10:00Z",
"effective_at": "2026-08-31T00:15:00Z",
"site": {"id": "site-east"},
"logical_device": {"id": "entry-camera-01"},
"profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frame_rate": 25},
"media": {"ref": "media:site-east/entry-01/main", "transport": "rtsp"},
"rule_set": {"version": "entry-rules-7", "state": "active", "profile_binding": {"profile_id": "main-stream", "width": 1280, "height": 720}, "areas": [], "directional_lines": []},
"integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"}
}
@@ -0,0 +1,13 @@
{
"schema_version": "yovision.source-config/v1",
"config_id": "school-east-entry-01",
"revision": 7,
"published_at": "2026-08-31T00:10:00Z",
"effective_at": "2026-08-31T00:15:00Z",
"site": {"id": "site-east"},
"logical_device": {"id": "entry-camera-01"},
"profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frame_rate": 25},
"media": {"ref": "media:site-east/entry-01/main?token=", "transport": "rtsp"},
"rule_set": {"version": "entry-rules-7", "state": "active", "profile_binding": {"profile_id": "main-stream", "width": 1920, "height": 1080}, "areas": [], "directional_lines": []},
"integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"}
}
@@ -0,0 +1,13 @@
{
"schema_version": "yovision.source-config/v2",
"config_id": "school-east-entry-01",
"revision": 7,
"published_at": "2026-08-31T00:10:00Z",
"effective_at": "2026-08-31T00:15:00Z",
"site": {"id": "site-east"},
"logical_device": {"id": "entry-camera-01"},
"profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frame_rate": 25},
"media": {"ref": "media:site-east/entry-01/main", "transport": "rtsp"},
"rule_set": {"version": "entry-rules-7", "state": "active", "profile_binding": {"profile_id": "main-stream", "width": 1920, "height": 1080}, "areas": [], "directional_lines": []},
"integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"}
}
@@ -0,0 +1,62 @@
{
"schema_version": "yovision.source-config/v1",
"config_id": "school-east-entry-01",
"revision": 7,
"published_at": "2026-08-31T00:10:00Z",
"effective_at": "2026-08-31T00:15:00Z",
"site": {
"id": "site-east"
},
"logical_device": {
"id": "entry-camera-01"
},
"profile": {
"id": "main-stream",
"width": 1920,
"height": 1080,
"encoding": "H264",
"frame_rate": 25
},
"media": {
"ref": "media:site-east/entry-01/main",
"transport": "rtsp"
},
"rule_set": {
"version": "entry-rules-7",
"state": "active",
"profile_binding": {
"profile_id": "main-stream",
"width": 1920,
"height": 1080
},
"areas": [
{
"id": "danger-yard",
"version": 3,
"kind": "danger_area",
"enabled": true,
"points": [
{"x": 0.12, "y": 0.18},
{"x": 0.82, "y": 0.18},
{"x": 0.76, "y": 0.78},
{"x": 0.18, "y": 0.72}
]
}
],
"directional_lines": [
{
"id": "entry-line",
"version": 2,
"kind": "directional_line",
"enabled": true,
"start": {"x": 0.2, "y": 0.5},
"end": {"x": 0.8, "y": 0.5},
"trigger_direction": "left_to_right"
}
]
},
"integrity": {
"algorithm": "sha256",
"value": "3336fe595bf1401b1024ac0c95c31e1655228485465a4527900fcea2c713acfe"
}
}
@@ -0,0 +1,39 @@
{
"schema_version": "yovision.source-config/v1",
"config_id": "school-east-entry-01",
"revision": 8,
"published_at": "2026-08-31T01:00:00Z",
"effective_at": "2026-08-31T01:00:00Z",
"site": {
"id": "site-east"
},
"logical_device": {
"id": "entry-camera-01"
},
"profile": {
"id": "main-stream-v2",
"width": 1280,
"height": 720,
"encoding": "H265",
"frame_rate": 20
},
"media": {
"ref": "media:site-east/entry-01/main-v2",
"transport": "rtsp"
},
"rule_set": {
"version": "entry-rules-8",
"state": "recalibration_required",
"profile_binding": {
"profile_id": "main-stream-v2",
"width": 1280,
"height": 720
},
"areas": [],
"directional_lines": []
},
"integrity": {
"algorithm": "sha256",
"value": "a53e6df8bab5c9a4e3f2dae2e82959939db09d34529af9ae65d66f322be833ba"
}
}
@@ -0,0 +1,35 @@
# 生产者与消费者 mapper 字段表
mapper 必须创建新的契约 DTO,不得直接序列化 Sense GORM 实体,也不得让 Brain 把共享快照当作 `brain.internal.input/v1`。
| 契约字段 | Sense 生产来源/规则 | Brain 消费目标/规则 |
|---|---|---|
| `schema_version` | 常量 `yovision.source-config/v1` | 在任何映射前精确校验;未知主版本拒绝 |
| `config_id` | 新的稳定配置聚合 ID;不是数据库行 ID 语义 | 作为配置流逻辑 ID,不解释为 Brain 内部对象 ID |
| `revision` | 聚合配置变更时严格递增;不可复用 | 与 `config_id` 共同做幂等、顺序和陈旧检查 |
| `published_at` / `effective_at` | 发布时写 UTC RFC 3339;生效不得早于发布 | 完整校验后按生效时间原子切换 |
| `site.id` | 对外稳定站点引用;不得映射客户名或数据库主键语义 | 仅作租户隔离后的逻辑关联;v1 不提供用户身份 |
| `logical_device.id` | `area.Definition.DeviceID` / `media.Route.DeviceID` 经稳定外部 ID mapper | 映射到 `BrainInputConfig.logical_device_id` |
| `profile.id` | `area.Definition.ProfileToken` 与 `media.Route.ProfileToken` 经稳定 Profile ID mapper | 映射到 `BrainInputConfig.profile.profile_id` |
| `profile.width/height/encoding` | `area.Definition.ProfileWidth/ProfileHeight/ProfileEncoding`;必须与当前媒体 Profile 一致 | 映射到规则 `RuleSet` 的 Profile 绑定;不一致拒绝 |
| `profile.frame_rate` | Sense 已验证 Profile 的帧率快照 | 映射到 `BrainInputConfig.profile.fps` |
| `media.ref` | 由 `media.Route.ID/Path` 生成 `media:<opaque-resource>`;禁止读取或拼入 `admissionProfile.StreamURI` 及凭据 | 交给后续 connector 解析;不得当作 RTSP URL 或本地路径 |
| `media.transport` | 当前固定 `rtsp`,仅描述媒体传输类别 | 选择后续 connector/decode adapter;不含认证信息 |
| `rule_set.version` | 由一组 `area.Version` 聚合成稳定规则集版本 | 映射到 Brain `RuleSet.version` |
| `rule_set.state` | `NeedsRecalibration=true` → `recalibration_required`;整体禁用 → `disabled`;否则 `active` | 只有 `active` 可构建并启用规则引擎 |
| `rule_set.profile_binding` | 与本快照 `profile.id/width/height` 同源复制并交叉校验 | 必须精确等于 `profile`;之后才接受归一化几何 |
| `rule_set.areas[].id/version` | `area.Version.DefinitionID/Version` 经稳定规则 ID mapper | 映射到 `AreaRule.rule_id`;version 用于可追溯性 |
| `rule_set.areas[].kind` | Sense `polygon` 映射为 `danger_area` | 只映射到 Brain 危险区域规则,不透传 Sense 枚举 |
| `rule_set.areas[].points` | `area.Version.GeometryJSON` 中 `{x,y}`;保持 0–1 | 映射到 Brain `Point(x,y)`;至少三点且非退化 |
| `rule_set.directional_lines[].id/version` | `area.Version.DefinitionID/Version` 经稳定规则 ID mapper | 映射到 `DirectionalLineRule.rule_id` |
| `rule_set.directional_lines[].start/end` | `direction_line` 几何的两个归一化点 | 映射到 Brain `Point`;相同点拒绝 |
| `rule_set.directional_lines[].trigger_direction` | Sense `forward/reverse` 必须由 mapper 根据已确认的起终点方向转换为 `left_to_right/right_to_left` | 映射到 `DirectionalLineRule.trigger_direction`;不得直接猜测枚举 |
| `integrity` | 对移除 `integrity` 的 JCS 快照计算 SHA-256 | 映射前重算并常量时间比较;失败保留上一有效 revision |
## 测试责任
- Sense 生产者契约测试:从设备、媒体 Route、Profile 与区域版本 fixture 生成快照;断言字段映射、revision 递增、Profile 变化触发新 revision/待重校准、无秘密媒体引用、Schema/语义/摘要通过。
- Brain 消费者契约测试:加载本目录有效与无效样例;断言版本拒绝、幂等/陈旧处理、Profile 绑定、坐标、规则 ID、状态门禁和摘要;再映射为 Brain 内部配置,证明共享 `schema_version` 不等于 `brain.internal.input/v1`。
- 协调契约测试(本工单):校验所有样例、秘密字段/URL/本地路径拒绝、跨字段语义和摘要。产品 adapter 测试在后续 connector 工单实施。
Sense 与 Brain 各自可增加内部字段,但不得将数据库主键、用户表、JWT、Cookie、摄像头凭据、客户内部路径或内部模型直接扩展进本契约。
@@ -0,0 +1,273 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://yovision.local/contracts/source-config/v1/source-config.schema.json",
"title": "YoVision Sense to Brain source configuration snapshot v1",
"description": "Credential-free media source, profile binding, and normalized rule configuration published by Sense for Brain.",
"type": "object",
"additionalProperties": false,
"required": [
"schema_version",
"config_id",
"revision",
"published_at",
"effective_at",
"site",
"logical_device",
"profile",
"media",
"rule_set",
"integrity"
],
"properties": {
"schema_version": {
"const": "yovision.source-config/v1"
},
"config_id": {
"$ref": "#/$defs/stable_id"
},
"revision": {
"type": "integer",
"minimum": 1
},
"published_at": {
"type": "string",
"format": "date-time"
},
"effective_at": {
"type": "string",
"format": "date-time"
},
"site": {
"type": "object",
"additionalProperties": false,
"required": ["id"],
"properties": {
"id": {
"$ref": "#/$defs/stable_id"
}
}
},
"logical_device": {
"type": "object",
"additionalProperties": false,
"required": ["id"],
"properties": {
"id": {
"$ref": "#/$defs/stable_id"
}
}
},
"profile": {
"$ref": "#/$defs/profile"
},
"media": {
"type": "object",
"additionalProperties": false,
"required": ["ref", "transport"],
"properties": {
"ref": {
"type": "string",
"pattern": "^media:[A-Za-z0-9][A-Za-z0-9._~/-]{0,254}$",
"description": "Opaque credential-free reference resolved by the connector. URI authority, userinfo, query strings, and fragments are forbidden."
},
"transport": {
"enum": ["rtsp"]
}
}
},
"rule_set": {
"type": "object",
"additionalProperties": false,
"required": [
"version",
"state",
"profile_binding",
"areas",
"directional_lines"
],
"properties": {
"version": {
"$ref": "#/$defs/stable_id"
},
"state": {
"enum": ["active", "disabled", "recalibration_required"]
},
"profile_binding": {
"$ref": "#/$defs/profile_binding"
},
"areas": {
"type": "array",
"items": {
"$ref": "#/$defs/area_rule"
},
"maxItems": 1024
},
"directional_lines": {
"type": "array",
"items": {
"$ref": "#/$defs/directional_line_rule"
},
"maxItems": 1024
}
}
},
"integrity": {
"type": "object",
"additionalProperties": false,
"required": ["algorithm", "value"],
"properties": {
"algorithm": {
"const": "sha256"
},
"value": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
}
}
},
"extensions": {
"type": "object",
"description": "Optional namespaced, non-secret extension data. Consumers ignore unknown namespaces.",
"propertyNames": {
"pattern": "^[A-Za-z][A-Za-z0-9.-]{0,127}$"
},
"additionalProperties": {
"type": "object"
}
}
},
"$defs": {
"stable_id": {
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[A-Za-z0-9][A-Za-z0-9._~-]*$"
},
"positive_integer": {
"type": "integer",
"minimum": 1
},
"positive_number": {
"type": "number",
"exclusiveMinimum": 0
},
"profile": {
"type": "object",
"additionalProperties": false,
"required": ["id", "width", "height", "encoding", "frame_rate"],
"properties": {
"id": {
"$ref": "#/$defs/stable_id"
},
"width": {
"$ref": "#/$defs/positive_integer"
},
"height": {
"$ref": "#/$defs/positive_integer"
},
"encoding": {
"enum": ["H264", "H265", "MJPEG"]
},
"frame_rate": {
"$ref": "#/$defs/positive_number"
}
}
},
"profile_binding": {
"type": "object",
"additionalProperties": false,
"required": ["profile_id", "width", "height"],
"properties": {
"profile_id": {
"$ref": "#/$defs/stable_id"
},
"width": {
"$ref": "#/$defs/positive_integer"
},
"height": {
"$ref": "#/$defs/positive_integer"
}
}
},
"point": {
"type": "object",
"additionalProperties": false,
"required": ["x", "y"],
"properties": {
"x": {
"type": "number",
"minimum": 0,
"maximum": 1
},
"y": {
"type": "number",
"minimum": 0,
"maximum": 1
}
}
},
"area_rule": {
"type": "object",
"additionalProperties": false,
"required": ["id", "version", "kind", "enabled", "points"],
"properties": {
"id": {
"$ref": "#/$defs/stable_id"
},
"version": {
"$ref": "#/$defs/positive_integer"
},
"kind": {
"const": "danger_area"
},
"enabled": {
"type": "boolean"
},
"points": {
"type": "array",
"items": {
"$ref": "#/$defs/point"
},
"minItems": 3,
"maxItems": 256
}
}
},
"directional_line_rule": {
"type": "object",
"additionalProperties": false,
"required": [
"id",
"version",
"kind",
"enabled",
"start",
"end",
"trigger_direction"
],
"properties": {
"id": {
"$ref": "#/$defs/stable_id"
},
"version": {
"$ref": "#/$defs/positive_integer"
},
"kind": {
"const": "directional_line"
},
"enabled": {
"type": "boolean"
},
"start": {
"$ref": "#/$defs/point"
},
"end": {
"$ref": "#/$defs/point"
},
"trigger_direction": {
"enum": ["left_to_right", "right_to_left"]
}
}
}
}
}
@@ -0,0 +1,91 @@
"""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
@@ -0,0 +1,17 @@
{
"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"
}
@@ -0,0 +1,16 @@
{
"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": []
}
@@ -0,0 +1,13 @@
{
"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
@@ -0,0 +1,110 @@
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)
@@ -0,0 +1,12 @@
{
"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"
}
@@ -0,0 +1,88 @@
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,2 @@
jsonschema==4.23.0
rfc8785==0.1.4
+33
View File
@@ -0,0 +1,33 @@
[CmdletBinding()]
param()
$ErrorActionPreference = 'Stop'
$testDirectory = $PSScriptRoot
$requirements = Join-Path $testDirectory 'requirements.txt'
$tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath())
$workDirectory = Join-Path $tempRoot ("yovision-source-config-v1-{0}" -f [Guid]::NewGuid().ToString('N'))
try {
New-Item -ItemType Directory -Path $workDirectory | Out-Null
$virtualEnvironment = Join-Path $workDirectory '.venv'
python -m venv $virtualEnvironment
if ($LASTEXITCODE -ne 0) { throw 'Failed to create the isolated Python environment.' }
$python = Join-Path $virtualEnvironment 'Scripts\python.exe'
$env:PIP_DISABLE_PIP_VERSION_CHECK = '1'
$env:PYTHONDONTWRITEBYTECODE = '1'
& $python -m pip install --quiet --requirement $requirements
if ($LASTEXITCODE -ne 0) { throw 'Failed to install pinned contract-test dependencies.' }
& $python -m unittest discover -s $testDirectory -p 'test_*.py' -v
if ($LASTEXITCODE -ne 0) { throw 'Source-config v1 contract tests failed.' }
}
finally {
$resolvedWorkDirectory = [IO.Path]::GetFullPath($workDirectory)
if (-not $resolvedWorkDirectory.StartsWith($tempRoot, [StringComparison]::OrdinalIgnoreCase)) {
throw "Refusing to remove a temporary directory outside $tempRoot"
}
if (Test-Path -LiteralPath $resolvedWorkDirectory) {
Remove-Item -LiteralPath $resolvedWorkDirectory -Recurse -Force
}
}
@@ -0,0 +1,261 @@
from __future__ import annotations
import copy
import hashlib
import json
import re
import unittest
from datetime import datetime
from pathlib import Path
from typing import Any
import rfc8785
from jsonschema import Draft202012Validator, FormatChecker
REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
CONTRACT_ROOT = REPOSITORY_ROOT / "contracts" / "source-config" / "v1"
SCHEMA_PATH = CONTRACT_ROOT / "source-config.schema.json"
VALID_ROOT = CONTRACT_ROOT / "examples" / "valid"
INVALID_ROOT = CONTRACT_ROOT / "examples" / "invalid"
FORBIDDEN_KEY = re.compile(r"(?:credential|password|secret|token|username|cookie|jwt)", re.IGNORECASE)
FORBIDDEN_MEDIA_CHARACTER = re.compile(r"[?@#\\]")
def load_json(path: Path) -> dict[str, Any]:
with path.open("r", encoding="utf-8") as handle:
value = json.load(handle)
if not isinstance(value, dict):
raise AssertionError(f"{path} must contain a JSON object")
return value
SCHEMA = load_json(SCHEMA_PATH)
VALIDATOR = Draft202012Validator(SCHEMA, format_checker=FormatChecker())
def integrity_value(payload: dict[str, Any]) -> str:
content = copy.deepcopy(payload)
content.pop("integrity", None)
return hashlib.sha256(rfc8785.dumps(content)).hexdigest()
def set_integrity(payload: dict[str, Any]) -> None:
payload["integrity"] = {"algorithm": "sha256", "value": integrity_value(payload)}
def reject_secrets(value: Any, path: str = "config") -> None:
if isinstance(value, dict):
for key, child in value.items():
if FORBIDDEN_KEY.search(str(key)):
raise ValueError(f"secret field is forbidden at {path}.{key}")
reject_secrets(child, f"{path}.{key}")
elif isinstance(value, list):
for index, child in enumerate(value):
reject_secrets(child, f"{path}[{index}]")
def polygon_area(points: list[dict[str, float]]) -> float:
return abs(
sum(
point["x"] * points[(index + 1) % len(points)]["y"]
- points[(index + 1) % len(points)]["x"] * point["y"]
for index, point in enumerate(points)
)
/ 2
)
def validate_payload(payload: dict[str, Any]) -> None:
if payload.get("schema_version") != "yovision.source-config/v1":
raise ValueError("unknown schema major version")
reject_secrets(payload)
media_ref = str(payload.get("media", {}).get("ref", ""))
if (
FORBIDDEN_MEDIA_CHARACTER.search(media_ref)
or "://" in media_ref
or re.match(r"^[A-Za-z]:", media_ref)
):
raise ValueError("secret, query, authority, or internal path in media reference")
errors = sorted(VALIDATOR.iter_errors(payload), key=lambda error: list(error.absolute_path))
if errors:
first = errors[0]
location = ".".join(str(part) for part in first.absolute_path) or "config"
raise ValueError(f"schema validation failed at {location}: {first.message}")
profile = payload["profile"]
binding = payload["rule_set"]["profile_binding"]
if (binding["profile_id"], binding["width"], binding["height"]) != (
profile["id"],
profile["width"],
profile["height"],
):
raise ValueError("profile binding does not match the media profile")
published_at = datetime.fromisoformat(payload["published_at"].replace("Z", "+00:00"))
effective_at = datetime.fromisoformat(payload["effective_at"].replace("Z", "+00:00"))
if effective_at < published_at:
raise ValueError("effective_at precedes published_at")
rule_set = payload["rule_set"]
rules = [*rule_set["areas"], *rule_set["directional_lines"]]
identifiers = [rule["id"] for rule in rules]
if len(identifiers) != len(set(identifiers)):
raise ValueError("rule ids must be unique across the rule set")
if rule_set["state"] == "recalibration_required" and any(rule["enabled"] for rule in rules):
raise ValueError("recalibration-required rules must not remain enabled")
for area in rule_set["areas"]:
if polygon_area(area["points"]) <= 1e-12:
raise ValueError(f"area {area['id']} is a degenerate polygon")
for line in rule_set["directional_lines"]:
if line["start"] == line["end"]:
raise ValueError(f"directional line {line['id']} has identical endpoints")
if payload["integrity"]["value"] != integrity_value(payload):
raise ValueError("integrity digest mismatch")
def validate_transition(previous: dict[str, Any], current: dict[str, Any]) -> None:
validate_payload(previous)
validate_payload(current)
if previous["config_id"] != current["config_id"]:
raise ValueError("config_id cannot change within one revision stream")
if current["revision"] <= previous["revision"]:
raise ValueError("revision must increase strictly")
previous_profile = previous["profile"]
current_profile = current["profile"]
profile_changed = any(
previous_profile[field] != current_profile[field]
for field in ("id", "width", "height", "encoding")
)
previous_rule_versions = sorted(
(rule["id"], rule["version"])
for rule in [*previous["rule_set"]["areas"], *previous["rule_set"]["directional_lines"]]
)
current_rule_versions = sorted(
(rule["id"], rule["version"])
for rule in [*current["rule_set"]["areas"], *current["rule_set"]["directional_lines"]]
)
if (
profile_changed
and previous_rule_versions == current_rule_versions
and current["rule_set"]["state"] != "recalibration_required"
):
raise ValueError("profile changed without rule recalibration state or new rule versions")
class SourceConfigV1ContractTests(unittest.TestCase):
def test_schema_is_valid_draft_2020_12(self) -> None:
Draft202012Validator.check_schema(SCHEMA)
def test_all_valid_examples_pass_schema_semantics_and_integrity(self) -> None:
examples = sorted(VALID_ROOT.glob("*.json"))
self.assertGreaterEqual(len(examples), 2)
for path in examples:
with self.subTest(path=path.name):
validate_payload(load_json(path))
def test_invalid_examples_fail_for_the_declared_reason(self) -> None:
expected = load_json(INVALID_ROOT / "expected-errors.json")
self.assertGreaterEqual(len(expected), 6)
for filename, reason in expected.items():
with self.subTest(path=filename):
with self.assertRaisesRegex(ValueError, str(reason)):
validate_payload(load_json(INVALID_ROOT / filename))
def test_tampering_is_detected_after_other_validation(self) -> None:
payload = load_json(VALID_ROOT / "active.json")
payload["revision"] += 1
with self.assertRaisesRegex(ValueError, "integrity digest mismatch"):
validate_payload(payload)
def test_namespaced_optional_extensions_are_compatible_but_not_secret_bearing(self) -> None:
payload = load_json(VALID_ROOT / "active.json")
payload["extensions"] = {"example.analytics": {"samplingHint": "balanced"}}
set_integrity(payload)
validate_payload(payload)
payload["extensions"] = {"example.analytics": {"accessToken": "forbidden"}}
set_integrity(payload)
with self.assertRaisesRegex(ValueError, "secret field"):
validate_payload(payload)
def test_profile_revision_and_recalibration_semantics_are_safe(self) -> None:
payload = load_json(VALID_ROOT / "active.json")
payload["rule_set"]["profile_binding"]["width"] = 1280
set_integrity(payload)
with self.assertRaisesRegex(ValueError, "profile binding"):
validate_payload(payload)
payload = load_json(VALID_ROOT / "active.json")
payload["rule_set"]["state"] = "recalibration_required"
set_integrity(payload)
with self.assertRaisesRegex(ValueError, "must not remain enabled"):
validate_payload(payload)
def test_revision_stream_rejects_stale_and_unrecalibrated_profile_change(self) -> None:
previous = load_json(VALID_ROOT / "active.json")
current = copy.deepcopy(previous)
current["revision"] = previous["revision"]
set_integrity(current)
with self.assertRaisesRegex(ValueError, "revision must increase"):
validate_transition(previous, current)
current["revision"] += 1
current["profile"].update({"id": "main-stream-v2", "width": 1280, "height": 720})
current["rule_set"]["profile_binding"].update(
{"profile_id": "main-stream-v2", "width": 1280, "height": 720}
)
set_integrity(current)
with self.assertRaisesRegex(ValueError, "without rule recalibration"):
validate_transition(previous, current)
validate_transition(previous, load_json(VALID_ROOT / "recalibration-required.json"))
def test_rule_geometry_and_global_ids_are_semantically_validated(self) -> None:
payload = load_json(VALID_ROOT / "active.json")
payload["rule_set"]["areas"][0]["points"] = [
{"x": 0, "y": 0},
{"x": 0.5, "y": 0.5},
{"x": 1, "y": 1},
]
set_integrity(payload)
with self.assertRaisesRegex(ValueError, "degenerate polygon"):
validate_payload(payload)
payload = load_json(VALID_ROOT / "active.json")
payload["rule_set"]["directional_lines"][0]["id"] = payload["rule_set"]["areas"][0]["id"]
set_integrity(payload)
with self.assertRaisesRegex(ValueError, "ids must be unique"):
validate_payload(payload)
def test_effective_time_cannot_precede_publication(self) -> None:
payload = load_json(VALID_ROOT / "active.json")
payload["effective_at"] = "2026-08-30T23:59:59Z"
set_integrity(payload)
with self.assertRaisesRegex(ValueError, "precedes"):
validate_payload(payload)
def test_shared_payload_does_not_claim_either_product_internal_model(self) -> None:
for path in sorted(VALID_ROOT.glob("*.json")):
serialized = json.dumps(load_json(path), ensure_ascii=False).lower()
self.assertNotIn("brain.internal.input", serialized)
self.assertNotIn("streamuri", serialized)
self.assertNotIn("profiletoken", serialized)
self.assertNotIn("database", serialized)
self.assertNotRegex(serialized, r"[a-z]:\\")
def test_mapper_documents_both_product_test_responsibilities(self) -> None:
mapper = (CONTRACT_ROOT / "mapper-fields.md").read_text(encoding="utf-8")
self.assertIn("Sense 生产者契约测试", mapper)
self.assertIn("Brain 消费者契约测试", mapper)
self.assertIn("brain.internal.input/v1", mapper)
self.assertIn("admissionProfile.StreamURI", mapper)
if __name__ == "__main__":
unittest.main()
+50 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Architecture-and-Code-Map.-
wiki_revision: 14b961599d6954357142713a5667fb37d38e86b7
synchronized_at: 2026-08-29T12:37:31Z
wiki_revision: 812e822990d8c8e82445bd19ced67aca8c10aba4
synchronized_at: 2026-08-31T01:58:55Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -296,3 +296,51 @@ Brain 解码层位于 `Brain/src/yovision_brain/decode/`,只依赖 #11 的内
内部候选包含逻辑输入引用、规则/模型版本、发生时间、匿名框和解释原因,不包含摄像头凭据、客户隐私、人脸、生物特征、机器绝对路径或证据引用。该格式不是 Brain→Bell 共享契约;Bell API、Outbox、机器身份、证据和跨项目投递必须由协调工单另行实现。
<!-- brain-local-events-v1:end -->
<!-- sense-brain-contracts-v1:start -->
## Sense↔Brain v1 契约边界
- Sense→Brain 配置:`contracts/source-config/v1/source-config.schema.json`;版本 `yovision.source-config/v1`。
- Brain→Sense 状态:`contracts/runtime-status/v1/runtime-status.schema.json`;版本 `yovision.runtime-status/v1`。
- 共同测试:`contracts/tests/source-config-v1/`、`contracts/tests/runtime-status-v1/`。
- 生产者/消费者 mapper 责任分别记录在 `mapper-fields.md` 与 `mapping.md`;产品 adapter 后续由 #152 实现。
数据流固定为:
```text
Sense Device/Profile/Area 内部事实
→ source-config/v1 mapper
→ Brain adapter(后续 #152)
→ Brain 内部配置与运行
→ runtime-status/v1 mapper
→ Sense 只读运维投影(后续 #152)
```
共享契约统一使用 snake_case 与 `schema_version: yovision.<contract>/v1`。源配置使用 `config_id + integer revision`;运行状态以 `configurations[]` 按 `config_id` 回报实际应用 revision。未知主版本、重复配置 ID、倒序状态、摘要失败或敏感字段必须拒绝,且不得覆盖最后已知有效配置/投影。
协议不得包含摄像头凭据、RTSP URL、query token、内部绝对路径、数据库模型、用户/JWT/Cookie 或 Bell Alert 语义。当前只冻结契约,没有新增网络端点、机器身份或跨端 connector。
<!-- sense-brain-contracts-v1:end -->
<!-- standard-event-evidence-v1:start -->
## 标准事件与证据 v1 契约边界
- Event Schema:`contracts/events/v1/event.schema.json`,版本 `yovision.event/v1`。
- Bell 接入描述:`contracts/events/v1/openapi.json`,返回创建、重复、幂等冲突和不支持版本等明确结果。
- Evidence Schema/API:`contracts/evidence/v1/evidence-reference.schema.json`、`openapi.json`,版本 `yovision.evidence-reference/v1`。
- 共同测试:`contracts/tests/events-v1/`、`contracts/tests/evidence-v1/`。
后续 #153 的映射流固定为:
```text
Brain internal candidate / Sense local event
→ yovision.event/v1 producer mapper
→ Sense Outbox relay(默认拓扑,保持原 producer/source ID)
→ Bell v1 ingress
→ Bell private immutable Event + permanent Receipt
→ Bell private Rule / Alert / ack / close
```
规范载荷使用 RFC 8785 JCS 与 SHA-256 形成稳定摘要。同键同摘要返回原 Event;同键不同摘要返回冲突并审计,不覆盖原事实。Evidence 只提供逻辑引用与状态/完整性元数据,不授予访问权限,不包含本机路径、签名 URL 或凭据;取证授权由后续机器身份和 connector 工单实现。
Brain candidate、Sense candidate/Outbox 与 Bell Event/Receipt/Alert 继续是各自内部模型。当前没有新增可运行的跨端 ingress/relay,不能把冻结 Schema 解释为端到端链路已完成。
<!-- standard-event-evidence-v1:end -->
+31 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Business-Rules-and-Glossary.-
wiki_revision: 27749cbf699093d997284afc277ea53e73a5876f
synchronized_at: 2026-08-29T12:37:41Z
wiki_revision: bc4a1a7be268028fa85717b71f48f7dd75cc7e52
synchronized_at: 2026-08-31T01:59:04Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -233,3 +233,32 @@ synchronized_at: 2026-08-29T12:37:41Z
- 确认和恢复都要求 6–256 字符原因、当前版本和允许的状态;旧版本或错误状态返回冲突。所有动作写入独立流转历史和 GoAdmin 操作审计。
- 运维告警永远设置为 Sense 内部运维记录,不创建本地安全事件或 Bell Alert,不进入跨项目 Outbox,也不实现通知升级。
<!-- sense-ops-alerts:end -->
<!-- sense-brain-contracts-v1:start -->
## Sense↔Brain 配置与状态规则
- **配置流**:由稳定 `config_id` 和严格递增的正整数 `revision` 标识;revision 不得复用或倒退。
- **无凭据媒体引用**:`media.ref` 是由后续 connector 解析的不透明逻辑引用,不是 RTSP URL、本机路径或数据库主键。
- **Profile 绑定**:规则集必须与 Profile ID、宽高一致;Profile 变化必须形成新 revision,并在需要时标记 `recalibration_required`,旧几何不得静默重投影。
- **规则坐标**:区域与方向线使用 0–1 归一化坐标,规则 ID 在同一规则集内唯一;退化多边形和重合线端点无效。
- **完整性**:源配置对移除 `integrity` 后的 JCS 表示计算 SHA-256;校验失败保留上一有效 revision。
- **配置应用状态**:Brain 在 `configurations[]` 中按 `config_id` 报告 `not_configured/applying/applied/rejected` 与实际 `applied_revision`;同一消息重复 ID 整条拒绝。
- **状态时序**:Brain 实例 sequence 单调递增;Sense 拒绝倒序消息。观测时间超过约定 90 秒时由 Sense 标记陈旧,不用未知值覆盖最后已知投影。
- **状态边界**:运行/健康错误只形成 Sense 运维投影,不是业务 Event 或 Bell Alert;不得包含用户会话、凭据、内部路径或客户视频。
- **版本兼容**:v1 只接受已冻结语义;破坏性字段或语义变化发布新主版本。未知主版本停止摄取并保留上一有效事实。
<!-- sense-brain-contracts-v1:end -->
<!-- standard-event-evidence-v1:start -->
## 标准事件、证据和幂等规则
- **标准 Event**:匿名、不可变的跨产品安全事实,不是 Bell Alert,也不携带处置或通知状态。
- **原始生产者**:`producer_id` 始终标识最初产生事件的 Brain 或 Sense 实例;relay 使用独立传输身份,但不得替换业务生产者。
- **永久幂等键**:精确 UTF-8 对 `(producer_id, source_event_id)`。重试沿用同一键,不生成新事件。
- **规范摘要**:完整 Event 使用 RFC 8785 JCS 规范化后计算 SHA-256。同键同摘要为重复成功;同键异摘要为终止性冲突,并追加脱敏审计。
- **时间格式**:Event v1 使用 UTC RFC 3339、三位毫秒和 `Z`;可选字段缺失时省略,不发送 null。
- **证据引用**:`evidence_id` 与 `owner_id` 是不透明逻辑引用,不是 URL、文件路径或访问凭据。
- **证据状态**:`pending → processing → success|failed`。success 要求内容类型和摘要/大小;failed 要求稳定错误码和是否可重试。
- **降级原则**:证据失败、未知或过期不删除 Event,不自动关闭 Alert,也不伪装成完整成功。
- **Bell 所有权**:Bell 独占内部 Event/Receipt、规则、Alert、ack、close、通知与用户审计;上游不得写入这些状态。
- **兼容与回退**:未知主版本终止接收但保留已有事实;破坏性变化发布新主版本。回退停用新生产者版本,不删除 Outbox、Receipt、Event 或审计。
<!-- standard-event-evidence-v1:end -->
+57 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Local-Development-and-Verification
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Local-Development-and-Verification.-
wiki_revision: e6068ff0e42765d32ff4e0ee0e8e51cf7d79b7da
synchronized_at: 2026-08-29T12:37:58Z
wiki_revision: d11757b202117e028878802e1e8a9ba9df1a8e89
synchronized_at: 2026-08-31T01:59:13Z
<!-- gitea-wiki-mirror:end -->
# 本地开发与验证
@@ -624,3 +624,58 @@ Brain\.venv\Scripts\python.exe -m yovision_brain.app --config Brain\tests\fixtur
CLI 将内部事件 JSON Lines 写入 stdout,并把 completed/cancelled、帧数、检测数和事件数摘要写入 stderr。配置文件必须显式提供,当前使用 JSON;无命中正常返回零事件,读取/配置/模块失败返回非零且不回显机器路径。命令不启动 Sense/Bell、不连接摄像头或网络。
<!-- brain-local-events-v1:end -->
<!-- sense-brain-contracts-v1:start -->
## Sense↔Brain v1 契约验证
源/规则配置契约:
```powershell
pwsh -NoProfile -File contracts/tests/source-config-v1/run.ps1
```
脚本在系统临时目录创建隔离虚拟环境,按固定依赖运行 Schema、跨字段语义、JCS/SHA-256、版本/重校准和秘密拒绝测试,结束后清理所属临时目录。
运行状态契约不需要第三方包:
```powershell
python contracts/tests/runtime-status-v1/test_contract.py
```
测试覆盖六态运行状态、30 秒未来时间偏差、90 秒陈旧边界、空/多配置流、四种配置应用状态、重复 `config_id`、integer revision mismatch、倒序消息、未知主版本、回退保留和敏感字段拒绝。
仓库级复核:
```powershell
python -m unittest discover -s tests -v
python dev_scripts/harness.py check --strict
git diff --check
```
这些命令只验证冻结契约,不验证 #152 产品 adapter、真实网络传输、机器身份、现场断网恢复或端到端链路。
<!-- sense-brain-contracts-v1:end -->
<!-- standard-event-evidence-v1:start -->
## 标准事件与证据 v1 契约验证
两组测试均只使用 Python 标准库:
```powershell
python contracts/tests/events-v1/test_contract.py
python contracts/tests/evidence-v1/test_contract.py
```
事件测试覆盖匿名危险区域/方向越线样例、Brain producer→Sense relay→Bell consumer mapper fixture、RFC 8785/SHA-256 幂等向量、重复/冲突、未知版本、敏感字段拒绝和 OpenAPI 引用。
证据测试覆盖 `pending/processing/success/failed` 状态约束、success 完整性、失败降级、旧 `available` 状态拒绝、敏感访问材料拒绝和证据 API 响应引用。
仓库级复核:
```powershell
python -m unittest discover -s tests -v
python dev_scripts/harness.py check --strict
git diff --check
```
这些测试只验证冻结契约,不验证 #153 产品 mapper/relay/ingress、#151 机器身份、实际证据存储/授权、网络断线补投或跨项目 E2E。
<!-- standard-event-evidence-v1:end -->
+22 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Product-Requirements
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Product-Requirements.-
wiki_revision: eac307b5aa55ff770ff034c53a65b70dc01cb00d
synchronized_at: 2026-08-29T12:39:32Z
wiki_revision: c9970b0ee8b677b6be13f31af67b3206a3faf956
synchronized_at: 2026-08-31T02:00:00Z
<!-- gitea-wiki-mirror:end -->
# 产品需求
@@ -272,3 +272,23 @@ BRN-002 的首个解码阶段已通过工单 #13 验收。Brain 通过可替换
内部候选只含逻辑输入引用、规则/模型版本、发生时间、匿名观测和解释原因,不含摄像头凭据、客户隐私、人脸、生物特征、机器绝对路径或伪造证据。该格式不是正式 Brain→Bell 契约;证据、机器身份、Outbox/可靠投递和跨项目 E2E 仍须协调工单实现。
<!-- brain-local-events-delivery:end -->
<!-- sense-brain-contracts-v1:start -->
## Sense↔Brain 首批冻结契约
工单 #148、#149 已于 2026-08-31 通过用户验收并合入 `dev`。Sense→Brain 源/规则配置的唯一共享事实源为 `contracts/source-config/v1/`,版本标识为 `yovision.source-config/v1`;Brain→Sense 运行状态的唯一共享事实源为 `contracts/runtime-status/v1/`,版本标识为 `yovision.runtime-status/v1`。
源配置按 `config_id + integer revision` 形成不可复用的配置流,携带逻辑站点/设备/Profile、无凭据媒体引用、归一化区域/方向线、规则版本与完整性摘要。运行状态按同一 `config_id` 在 `configurations[]` 中报告实际应用 revision,并包含 Brain 实例、运行/模型版本、健康、输入和稳定错误码。
这两项只冻结协议和测试,不表示 #152 connector 已实现。Sense 与 Brain 仍可独立运行;Brain 不读取 Sense 数据库,Sense 不读取 Brain 内部状态。既有 `brain.internal.*`、Sense GORM 模型和运维投影继续是项目内部实现,不得直接作为共享协议。
<!-- sense-brain-contracts-v1:end -->
<!-- standard-event-evidence-v1:start -->
## 标准事件与证据引用冻结契约
工单 #150 已于 2026-08-31 通过用户验收并合入 `dev`。Sense/Brain→Bell 标准匿名安全事件的唯一共享事实源为 `contracts/events/v1/`,版本标识 `yovision.event/v1`;证据逻辑引用的唯一共享事实源为 `contracts/evidence/v1/`,版本标识 `yovision.evidence-reference/v1`。
事件以原始 `(producer_id, source_event_id)` 永久幂等,Sense relay 不改变原始身份或业务载荷。事件只携带逻辑站点/设备/Profile、事件类型、发生时间、规则/模型版本、匿名观测、区域和证据逻辑引用,不携带用户会话、摄像头凭据、内部路径、人脸特征或 Alert/ack/close 状态。
证据状态为 `pending/processing/success/failed`;`success` 必须包含内容类型和 SHA-256 完整性元数据,失败或过期只降级证据,不改写不可变 Event 或 Bell Alert 生命周期。此工单只冻结契约和测试,#153 可靠 connector、机器身份、证据存储与实际授权取证尚未实现。
<!-- standard-event-evidence-v1:end -->
+10 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Deployment-and-Operations
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Deployment-and-Operations.-
wiki_revision: b21bbc64f323f465b78b557537c38e334de31f45
synchronized_at: 2026-08-29T12:41:01Z
wiki_revision: 0a772b0511044d98430ebd93304faa3dee57183d
synchronized_at: 2026-08-31T01:39:35Z
<!-- gitea-wiki-mirror:end -->
# YoVision 部署与运维
@@ -134,3 +134,11 @@ Sense\start_sense.bat
运维告警排错不得粘贴设备地址、Stream URI、摄像头凭据、JWT、Cookie 或数据库连接。需要回退时可停止使用刷新/处置入口,但不得删除 `sense_ops_alerts` 或 `sense_ops_alert_transitions` 历史;规则语义变化必须另建工单。
<!-- sense-ops-alerts:end -->
<!-- sense-brain-contracts-v1:start -->
## Sense↔Brain 契约部署边界
`yovision.source-config/v1` 与 `yovision.runtime-status/v1` 已冻结,但当前没有因此新增监听端口、服务进程、机器凭据或根级编排。#148/#149 只交付 `contracts/**` Schema、样例、兼容说明和契约测试;实际 Sense↔Brain 传输、认证、超时、退避、重启恢复及配置/状态 adapter 由后续 #151、#152 实现和验收。
因此现阶段部署仍按 Sense、Brain 各自独立入口进行,不得手工共享数据库、用户 JWT/Cookie、摄像头凭据、文件目录或临时 JSON 字段来提前打通。需要停用或回退时保持两端独立运行,并保留上一已确认的配置与最后已知状态;未知协议主版本必须停止摄取而不是覆盖投影。
<!-- sense-brain-contracts-v1:end -->