diff --git a/contracts/events/v1/README.md b/contracts/events/v1/README.md new file mode 100644 index 0000000..944cd53 --- /dev/null +++ b/contracts/events/v1/README.md @@ -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` 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. diff --git a/contracts/events/v1/event.schema.json b/contracts/events/v1/event.schema.json new file mode 100644 index 0000000..a9b7002 --- /dev/null +++ b/contracts/events/v1/event.schema.json @@ -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"}}}}} + } + ] +} diff --git a/contracts/events/v1/examples/dangerous-area.json b/contracts/events/v1/examples/dangerous-area.json new file mode 100644 index 0000000..4de0699 --- /dev/null +++ b/contracts/events/v1/examples/dangerous-area.json @@ -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" + } + ] +} diff --git a/contracts/events/v1/examples/directional-line-crossed.json b/contracts/events/v1/examples/directional-line-crossed.json new file mode 100644 index 0000000..3f67fc6 --- /dev/null +++ b/contracts/events/v1/examples/directional-line-crossed.json @@ -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} + } + ] +} diff --git a/contracts/events/v1/examples/duplicate-result.json b/contracts/events/v1/examples/duplicate-result.json new file mode 100644 index 0000000..4521199 --- /dev/null +++ b/contracts/events/v1/examples/duplicate-result.json @@ -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" +} diff --git a/contracts/events/v1/examples/idempotency-conflict-problem.json b/contracts/events/v1/examples/idempotency-conflict-problem.json new file mode 100644 index 0000000..0855fbd --- /dev/null +++ b/contracts/events/v1/examples/idempotency-conflict-problem.json @@ -0,0 +1,5 @@ +{ + "code": "idempotency_conflict", + "message": "idempotency key already belongs to another canonical payload", + "existing_event_id": "bell-event-00000042" +} diff --git a/contracts/events/v1/examples/unsupported-version-problem.json b/contracts/events/v1/examples/unsupported-version-problem.json new file mode 100644 index 0000000..ad4b6bc --- /dev/null +++ b/contracts/events/v1/examples/unsupported-version-problem.json @@ -0,0 +1,5 @@ +{ + "code": "unsupported_schema_version", + "message": "schema_version yovision.event/v2 is not accepted", + "field": "schema_version" +} diff --git a/contracts/events/v1/ingest-result.schema.json b/contracts/events/v1/ingest-result.schema.json new file mode 100644 index 0000000..f82843e --- /dev/null +++ b/contracts/events/v1/ingest-result.schema.json @@ -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}$"} + } +} diff --git a/contracts/events/v1/openapi.json b/contracts/events/v1/openapi.json new file mode 100644 index 0000000..ef3d3f8 --- /dev/null +++ b/contracts/events/v1/openapi.json @@ -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"}}}} + } + } + } + } +} diff --git a/contracts/events/v1/problem.schema.json b/contracts/events/v1/problem.schema.json new file mode 100644 index 0000000..fc875b8 --- /dev/null +++ b/contracts/events/v1/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}$"} + } +} diff --git a/contracts/evidence/v1/README.md b/contracts/evidence/v1/README.md new file mode 100644 index 0000000..be638e8 --- /dev/null +++ b/contracts/evidence/v1/README.md @@ -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. +- `available`: `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 -> available|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 +``` diff --git a/contracts/evidence/v1/evidence-reference.schema.json b/contracts/evidence/v1/evidence-reference.schema.json new file mode 100644 index 0000000..01c3461 --- /dev/null +++ b/contracts/evidence/v1/evidence-reference.schema.json @@ -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", "available", "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": "available"}}, "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"]}]}} + } + ] +} diff --git a/contracts/evidence/v1/examples/failed.json b/contracts/evidence/v1/examples/failed.json new file mode 100644 index 0000000..aaa326d --- /dev/null +++ b/contracts/evidence/v1/examples/failed.json @@ -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} +} diff --git a/contracts/evidence/v1/examples/pending.json b/contracts/evidence/v1/examples/pending.json new file mode 100644 index 0000000..90c5767 --- /dev/null +++ b/contracts/evidence/v1/examples/pending.json @@ -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" +} diff --git a/contracts/evidence/v1/examples/success.json b/contracts/evidence/v1/examples/success.json new file mode 100644 index 0000000..a54fb28 --- /dev/null +++ b/contracts/evidence/v1/examples/success.json @@ -0,0 +1,16 @@ +{ + "schema_version": "yovision.evidence-reference/v1", + "evidence_id": "ev-school-east-0001", + "owner_id": "sense-school-a", + "type": "snapshot", + "status": "available", + "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 + } +} diff --git a/contracts/evidence/v1/openapi.json b/contracts/evidence/v1/openapi.json new file mode 100644 index 0000000..8a0dbbc --- /dev/null +++ b/contracts/evidence/v1/openapi.json @@ -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 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"}}}} + } + } + } + } +} diff --git a/contracts/tests/events-v1/contract_support.py b/contracts/tests/events-v1/contract_support.py new file mode 100644 index 0000000..c0631d8 --- /dev/null +++ b/contracts/tests/events-v1/contract_support.py @@ -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 diff --git a/contracts/tests/events-v1/fixtures/sensitive-field.json b/contracts/tests/events-v1/fixtures/sensitive-field.json new file mode 100644 index 0000000..254e311 --- /dev/null +++ b/contracts/tests/events-v1/fixtures/sensitive-field.json @@ -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" +} diff --git a/contracts/tests/events-v1/fixtures/unknown-version.json b/contracts/tests/events-v1/fixtures/unknown-version.json new file mode 100644 index 0000000..f7224f1 --- /dev/null +++ b/contracts/tests/events-v1/fixtures/unknown-version.json @@ -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": [] +} diff --git a/contracts/tests/events-v1/idempotency-vectors.json b/contracts/tests/events-v1/idempotency-vectors.json new file mode 100644 index 0000000..2f9d11d --- /dev/null +++ b/contracts/tests/events-v1/idempotency-vectors.json @@ -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" + } + ] +} diff --git a/contracts/tests/events-v1/test_contract.py b/contracts/tests/events-v1/test_contract.py new file mode 100644 index 0000000..b526d37 --- /dev/null +++ b/contracts/tests/events-v1/test_contract.py @@ -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) diff --git a/contracts/tests/evidence-v1/fixtures/sensitive-field.json b/contracts/tests/evidence-v1/fixtures/sensitive-field.json new file mode 100644 index 0000000..b8cc67b --- /dev/null +++ b/contracts/tests/evidence-v1/fixtures/sensitive-field.json @@ -0,0 +1,12 @@ +{ + "schema_version": "yovision.evidence-reference/v1", + "evidence_id": "ev-sensitive-0001", + "owner_id": "sense-school-a", + "type": "snapshot", + "status": "available", + "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" +} diff --git a/contracts/tests/evidence-v1/test_contract.py b/contracts/tests/evidence-v1/test_contract.py new file mode 100644 index 0000000..1122927 --- /dev/null +++ b/contracts/tests/evidence-v1/test_contract.py @@ -0,0 +1,81 @@ +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: + available = load_json(CONTRACTS / "evidence" / "v1" / "examples" / "success.json") + del available["integrity"] + self.assertTrue(self.errors_for(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)