Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b548b05874 | ||
|
|
23a85278cb | ||
|
|
96777a948f | ||
|
|
359c553452 | ||
|
|
2a395aa126 |
@@ -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.
|
||||
@@ -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}$"}
|
||||
}
|
||||
}
|
||||
@@ -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"}}}}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}$"}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"}}}}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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: 3327861dbd77ae63fd10246533b87e0b99a69220
|
||||
synchronized_at: 2026-08-31T01:38:23Z
|
||||
wiki_revision: 812e822990d8c8e82445bd19ced67aca8c10aba4
|
||||
synchronized_at: 2026-08-31T01:58:55Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 架构与代码地图
|
||||
@@ -320,3 +320,27 @@ Sense Device/Profile/Area 内部事实
|
||||
|
||||
协议不得包含摄像头凭据、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 -->
|
||||
|
||||
@@ -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: feca453cd0873c3ad4cef243acfada08ef3f1e33
|
||||
synchronized_at: 2026-08-31T01:38:28Z
|
||||
wiki_revision: bc4a1a7be268028fa85717b71f48f7dd75cc7e52
|
||||
synchronized_at: 2026-08-31T01:59:04Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -247,3 +247,18 @@ synchronized_at: 2026-08-31T01:38:28Z
|
||||
- **状态边界**:运行/健康错误只形成 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 -->
|
||||
|
||||
@@ -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: 43eb0f69fbb4d573a5cd1c61b97dbc9fc9d2a49b
|
||||
synchronized_at: 2026-08-31T01:38:33Z
|
||||
wiki_revision: d11757b202117e028878802e1e8a9ba9df1a8e89
|
||||
synchronized_at: 2026-08-31T01:59:13Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 本地开发与验证
|
||||
@@ -654,3 +654,28 @@ 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 -->
|
||||
|
||||
@@ -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: 28dce0d89804013411f8111ba421c93e9a1dfa2c
|
||||
synchronized_at: 2026-08-31T01:39:04Z
|
||||
wiki_revision: c9970b0ee8b677b6be13f31af67b3206a3faf956
|
||||
synchronized_at: 2026-08-31T02:00:00Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 产品需求
|
||||
@@ -282,3 +282,13 @@ BRN-002 的首个解码阶段已通过工单 #13 验收。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 -->
|
||||
|
||||
Reference in New Issue
Block a user