Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
68 commits
Select commit Hold shift + click to select a range
c46bff1
docs: add INT_01 event evidence PRD and swarm plan
matteius Nov 8, 2026
3ffc740
docs: freeze INT_01 observations integration contract (C1)
matteius Nov 8, 2026
ec57577
docs: mark INT_01 task C1 completed in plan
matteius Nov 8, 2026
3c61e23
docs: mark INT_01 task W0 completed in plan
matteius Nov 8, 2026
7c2245a
feat(db): add migration 0095 for object observations (INT_01 L1)
matteius Nov 8, 2026
8dcbc32
docs: mark INT_01 task L1 completed in plan
matteius Nov 8, 2026
a847d2b
feat(api): add INT_01 observations contract to OpenAPI with 501 stubs…
matteius Nov 8, 2026
5fcc894
docs: mark INT_01 task C2 completed in plan
matteius Nov 8, 2026
1f64fea
docs: mark INT_01 task W1 completed in plan
matteius Nov 8, 2026
b0c17dd
test(perf): declare INT_01 observation workload and disabled baseline…
matteius Nov 8, 2026
76de1ea
docs: mark INT_01 task C4 completed in plan
matteius Nov 8, 2026
d968b4d
test(contracts): add INT_01 shared observation fixtures (C3)
matteius Nov 8, 2026
dcc67d0
docs: mark INT_01 task C3 completed in plan
matteius Nov 8, 2026
f691554
docs: mark INT_01 task W2 completed in plan
matteius Nov 8, 2026
49bda76
feat(db): add observation DB layer with cursor history (INT_01 L2)
matteius Nov 8, 2026
faba913
docs: mark INT_01 task L2 completed in plan
matteius Nov 8, 2026
7373d59
feat(core): add observation evidence reconciliation and retention (IN…
matteius Nov 8, 2026
a508e8a
docs: mark INT_01 task L4 completed in plan
matteius Nov 8, 2026
aca8d32
docs: mark INT_01 task W5 completed in plan
matteius Nov 8, 2026
442ab71
feat(core): add observation publication relay and change event (INT_0…
matteius Nov 8, 2026
62d8c83
docs: mark INT_01 task L5 completed in plan
matteius Nov 8, 2026
5247c8a
feat(core): add observation evidence queue and encoder worker (INT_01…
matteius Nov 8, 2026
0bf32a5
docs: mark INT_01 task L3 completed in plan
matteius Nov 8, 2026
2dfe559
feat(web): implement observation and capability API handlers (INT_01 L8)
matteius Nov 8, 2026
2dd99c8
docs: mark INT_01 task L8 completed in plan
matteius Nov 8, 2026
e365b6b
feat(streams): per-camera observation evidence opt-in and budgets (IN…
matteius Nov 8, 2026
722d63c
docs: mark INT_01 task L9 completed in plan
matteius Nov 8, 2026
de98418
docs: mark INT_01 task W3 completed in plan
matteius Nov 8, 2026
7c7082b
feat(video): capture observation evidence from local detectors (INT_0…
matteius Nov 8, 2026
1b8862d
docs: mark INT_01 task L6 completed in plan
matteius Nov 8, 2026
3b86a6d
feat(db): add observation classifications and observations.annotate (…
matteius Nov 8, 2026
4cb76a1
docs: mark INT_01 task L13 completed in plan
matteius Nov 8, 2026
b2d4891
docs: mark INT_01 task W4 completed in plan
matteius Nov 8, 2026
9830adb
feat(video): HTTP API and ONVIF observation evidence (INT_01 L7)
matteius Nov 8, 2026
76231ed
docs: mark INT_01 task L7 completed in plan
matteius Nov 8, 2026
9e0e8ec
feat(web): implement the observation classification PUT (INT_01 L14)
matteius Nov 8, 2026
2dcdf9a
docs: mark INT_01 task L14 completed in plan
matteius Nov 8, 2026
9f6012a
feat(diagnostics): observation pipeline metrics and system view (INT_…
matteius Nov 8, 2026
18db2e8
docs: mark INT_01 task L10 completed in plan
matteius Nov 8, 2026
fb9fea9
docs: mark INT_01 task W7 completed in plan
matteius Nov 8, 2026
3a28e5e
docs: mark INT_01 task W8 completed in plan
matteius Nov 8, 2026
cde7480
docs: mark INT_01 task W6 completed in plan
matteius Nov 8, 2026
119fad5
feat(web): show linked observation classifications beside detections …
matteius Nov 8, 2026
a180ba2
docs: mark INT_01 task L15 completed in plan
matteius Nov 8, 2026
deefe89
docs: mark INT_01 task W10 completed in plan
matteius Nov 8, 2026
ddea740
test(observations): contract and crash-boundary reliability suite (IN…
matteius Nov 8, 2026
cfce349
docs: mark INT_01 task L11 completed in plan
matteius Nov 8, 2026
b6d4645
docs: mark INT_01 task W9 completed in plan
matteius Nov 8, 2026
271f319
docs(int01): YA-WAMF deployment recipe and version matrix; document o…
matteius Nov 8, 2026
e294f95
docs: mark INT_01 task L12 completed in plan
matteius Nov 8, 2026
def9367
test(int01): lightNVR + YA-WAMF end-to-end P1 suite and A14 pilot che…
matteius Nov 8, 2026
bcf5290
docs: mark INT_01 task X1 completed in plan
matteius Nov 8, 2026
817796c
test(int01): P2 write-back end to end with YA-WAMF (A12, A13, A7-anno…
matteius Nov 8, 2026
cdab565
docs: mark INT_01 task X3 completed in plan
matteius Nov 8, 2026
9ed02bb
fix(int01): release rejected evidence frames on the worker; submit-la…
matteius Nov 8, 2026
66270dd
test(int01): X2 saturation and performance release gate results; fina…
matteius Nov 8, 2026
7e84570
docs: mark INT_01 task X2 completed in plan
matteius Nov 8, 2026
861a771
fix(int01): keep the C->Rust conversion building; label-neutral evide…
matteius Nov 8, 2026
baba083
fix(int01): sweep evidence files relative to directory fds (CodeQL TO…
matteius Nov 8, 2026
69a5888
fix(int01): use atomic_int flags in evidence and retention for the Ru…
matteius Nov 8, 2026
edf97c3
lightnvr-rs: regenerate from C
github-actions[bot] Nov 8, 2026
46778cc
test(int01): close harness log handles and drop dead variables
matteius Nov 8, 2026
3ddcc03
feat(go2rtc): accept a list of External IPs as WebRTC candidates
matteius Nov 8, 2026
1c0d071
fix(onvif): say why a connection test failed; retry refused rtsps as …
matteius Nov 8, 2026
cf036d4
lightnvr-rs: regenerate from C
github-actions[bot] Nov 8, 2026
3bfbdab
fix(go2rtc): build the External IP parser without go2rtc
matteius Nov 8, 2026
cd54889
fix(onvif): send the rebuilt SOAP envelope on retry, not freed memory
matteius Nov 8, 2026
ea6ff0b
lightnvr-rs: regenerate from C
github-actions[bot] Nov 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
186 changes: 186 additions & 0 deletions db/migrations/0095_add_object_observations.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
-- INT_01 durable object observations, inference-frame evidence assets, the
-- monotonic change log behind the history cursor, and transactional
-- publication staging for the event relay. Normative contract:
-- docs/integrations/OBSERVATIONS_CONTRACT.md (sections 3-5, 7, 13, 14).
--
-- Every observation references an asset row (contract D5); observations with
-- no media reference an 'unavailable' asset. Reason values stay open text so a
-- new reason is a minor contract change, but the reason must be NULL exactly
-- when the state is pending or ready. Provisional budget bounds (section 13)
-- are enforced by the API, not by CHECKs, so tuning them needs no rebuild.

-- migrate:up

CREATE TABLE observation_assets (
uuid TEXT PRIMARY KEY,
camera_uuid TEXT NOT NULL,
state TEXT NOT NULL CHECK (state IN ('pending', 'ready', 'unavailable', 'expired')),
reason TEXT CHECK (reason IS NULL OR length(reason) BETWEEN 1 AND 32),
origin TEXT CHECK (origin IS NULL OR origin IN ('inference_input', 'submitted_image')),
media_type TEXT,
path TEXT,
bytes INTEGER CHECK (bytes IS NULL OR bytes >= 0),
width INTEGER CHECK (width IS NULL OR width > 0),
height INTEGER CHECK (height IS NULL OR height > 0),
captured_at_ms INTEGER,
transform_json TEXT,
protected INTEGER NOT NULL DEFAULT 0 CHECK (protected IN (0, 1)),
expires_at_ms INTEGER,
created_at_ms INTEGER NOT NULL,
updated_at_ms INTEGER NOT NULL,
CHECK ((state IN ('pending', 'ready')) = (reason IS NULL))
);

-- Reconciliation scans by state; retention walks one camera's ready assets
-- oldest first.
CREATE INDEX idx_observation_assets_state_camera
ON observation_assets(state, camera_uuid, created_at_ms);

CREATE TABLE object_observations (
uuid TEXT PRIMARY KEY,
camera_uuid TEXT NOT NULL,
stream_name_at_capture TEXT NOT NULL,
batch_id TEXT NOT NULL,
object_index INTEGER NOT NULL CHECK (object_index BETWEEN 0 AND 19),
observed_at_ms INTEGER NOT NULL,
created_at_ms INTEGER NOT NULL,
engine_key TEXT NOT NULL,
engine_type TEXT NOT NULL CHECK (engine_type IN ('object', 'api', 'onvif', 'external')),
model TEXT,
model_version TEXT,
label TEXT NOT NULL CHECK (length(label) BETWEEN 1 AND 31),
confidence REAL NOT NULL CHECK (confidence >= 0.0 AND confidence <= 1.0),
box_x REAL,
box_y REAL,
box_width REAL,
box_height REAL,
track_id TEXT,
track_namespace TEXT,
track_session TEXT,
recording_id INTEGER REFERENCES recordings(id) ON DELETE SET NULL,
asset_uuid TEXT REFERENCES observation_assets(uuid),
revision INTEGER NOT NULL DEFAULT 1 CHECK (revision >= 1),
UNIQUE(batch_id, object_index),
-- Normalized xywh relative to the stored evidence image; all or nothing.
-- The 1e-6 slack absorbs rounding to 6 decimals. IS NOT NULL is explicit
-- because a CHECK that evaluates to NULL passes.
CHECK ((box_x IS NULL AND box_y IS NULL AND box_width IS NULL AND box_height IS NULL)
OR (box_x IS NOT NULL AND box_y IS NOT NULL
AND box_width IS NOT NULL AND box_height IS NOT NULL
AND box_x >= 0.0 AND box_y >= 0.0
AND box_width > 0.0 AND box_height > 0.0
AND box_x + box_width <= 1.000001 AND box_y + box_height <= 1.000001)),
CHECK ((track_id IS NULL AND track_namespace IS NULL AND track_session IS NULL)
OR (length(track_id) BETWEEN 1 AND 64
AND track_namespace IS NOT NULL AND track_session IS NOT NULL))
);

CREATE INDEX idx_object_observations_camera_time
ON object_observations(camera_uuid, observed_at_ms);

CREATE INDEX idx_object_observations_label_time
ON object_observations(label, observed_at_ms);

CREATE INDEX idx_object_observations_created
ON object_observations(created_at_ms);

-- Asset transitions bump every referencing observation; refcount is derived.
CREATE INDEX idx_object_observations_asset
ON object_observations(asset_uuid);

-- Keeps the ON DELETE SET NULL action cheap on every recording delete.
CREATE INDEX idx_object_observations_recording
ON object_observations(recording_id);

-- seq is the history cursor: monotonic persistence order, never reused.
CREATE TABLE observation_changes (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
observation_uuid TEXT NOT NULL REFERENCES object_observations(uuid) ON DELETE CASCADE,
revision INTEGER NOT NULL CHECK (revision >= 1),
cause TEXT NOT NULL CHECK (cause IN ('created', 'evidence', 'classification')),
evidence_state TEXT NOT NULL CHECK (evidence_state IN ('pending', 'ready', 'unavailable', 'expired')),
evidence_reason TEXT CHECK (evidence_reason IS NULL OR length(evidence_reason) BETWEEN 1 AND 32),
created_at_ms INTEGER NOT NULL,
UNIQUE(observation_uuid, revision),
CHECK ((evidence_state IN ('pending', 'ready')) = (evidence_reason IS NULL)),
CHECK ((cause = 'created') = (revision = 1))
);

CREATE TABLE observation_publications (
change_seq INTEGER PRIMARY KEY REFERENCES observation_changes(seq) ON DELETE CASCADE,
message_id TEXT NOT NULL UNIQUE,
state TEXT NOT NULL DEFAULT 'pending' CHECK (state IN (
'pending', 'relayed', 'relayed_no_route', 'expired', 'failed'
)),
attempts INTEGER NOT NULL DEFAULT 0 CHECK (attempts >= 0),
last_error TEXT,
updated_at_ms INTEGER NOT NULL
);

-- The relay reads pending rows in seq order.
CREATE INDEX idx_observation_publications_pending
ON observation_publications(change_seq) WHERE state = 'pending';

-- One row. epoch_uuid identifies this DB generation for cursors (contract
-- D9); oldest_seq is the lowest seq that may still be retained (prune
-- boundary, initially 1). Kept out of system_settings so it can be written
-- inside observation transactions.
CREATE TABLE observation_meta (
id INTEGER PRIMARY KEY CHECK (id = 1),
epoch_uuid TEXT NOT NULL CHECK (length(epoch_uuid) = 36),
oldest_seq INTEGER NOT NULL DEFAULT 1 CHECK (oldest_seq >= 1)
);

INSERT INTO observation_meta (id, epoch_uuid, oldest_seq)
VALUES (
1,
lower(
hex(randomblob(4)) || '-' ||
hex(randomblob(2)) || '-4' ||
substr(hex(randomblob(2)), 2) || '-' ||
substr('89ab', (abs(random()) % 4) + 1, 1) ||
substr(hex(randomblob(2)), 2) || '-' ||
hex(randomblob(6))
),
1
);

-- Per-camera evidence settings (contract section 13). Evidence is off by
-- default; an empty label list means every object label.
ALTER TABLE streams ADD COLUMN observation_evidence_enabled INTEGER NOT NULL DEFAULT 0
CHECK (observation_evidence_enabled IN (0, 1));
ALTER TABLE streams ADD COLUMN observation_labels TEXT NOT NULL DEFAULT '';
ALTER TABLE streams ADD COLUMN observation_max_age_days INTEGER NOT NULL DEFAULT 7
CHECK (observation_max_age_days > 0);
ALTER TABLE streams ADD COLUMN observation_max_bytes INTEGER NOT NULL DEFAULT 2147483648
CHECK (observation_max_bytes > 0);

INSERT OR IGNORE INTO system_settings (key, value)
VALUES ('observation_history_retention_days', '30');

INSERT OR IGNORE INTO system_settings (key, value)
VALUES ('observation_global_queue_bytes', '33554432');

-- migrate:down

-- Drops all observation history and evidence metadata; take a backup first.
-- Evidence files under <storage_path>/observations are left on disk.
DELETE FROM system_settings WHERE key IN (
'observation_history_retention_days', 'observation_global_queue_bytes'
);
ALTER TABLE streams DROP COLUMN observation_max_bytes;
ALTER TABLE streams DROP COLUMN observation_max_age_days;
ALTER TABLE streams DROP COLUMN observation_labels;
ALTER TABLE streams DROP COLUMN observation_evidence_enabled;
DROP TABLE IF EXISTS observation_meta;
DROP INDEX IF EXISTS idx_observation_publications_pending;
DROP TABLE IF EXISTS observation_publications;
DROP TABLE IF EXISTS observation_changes;
DROP INDEX IF EXISTS idx_object_observations_recording;
DROP INDEX IF EXISTS idx_object_observations_asset;
DROP INDEX IF EXISTS idx_object_observations_created;
DROP INDEX IF EXISTS idx_object_observations_label_time;
DROP INDEX IF EXISTS idx_object_observations_camera_time;
DROP TABLE IF EXISTS object_observations;
DROP INDEX IF EXISTS idx_observation_assets_state_camera;
DROP TABLE IF EXISTS observation_assets;
54 changes: 54 additions & 0 deletions db/migrations/0096_add_observation_classifications.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
-- INT_01 P2 classification annotations on object observations and the
-- observations.annotate authorization action. Normative contract:
-- docs/integrations/OBSERVATIONS_CONTRACT.md (sections 10, 12.1).
--
-- One row per (observation, "<writer_namespace>/<name>") key. Write authority
-- is pinned to writer_user_id, stored when the key is created (D3): user ids
-- are never reused, so a later user with the same username cannot overwrite.
-- There is no foreign key to users, so annotations survive user deletion and
-- become read-only. Times are epoch milliseconds. The original detector
-- fields on object_observations are never modified by an annotation.

-- migrate:up

CREATE TABLE observation_classifications (
observation_uuid TEXT NOT NULL REFERENCES object_observations(uuid) ON DELETE CASCADE,
key TEXT NOT NULL CHECK (length(key) BETWEEN 3 AND 65),
writer_namespace TEXT NOT NULL CHECK (length(writer_namespace) BETWEEN 1 AND 32),
writer_user_id INTEGER NOT NULL CHECK (writer_user_id > 0),
classifier TEXT NOT NULL CHECK (length(classifier) BETWEEN 1 AND 64),
model_version TEXT CHECK (model_version IS NULL OR length(model_version) BETWEEN 1 AND 64),
label TEXT NOT NULL CHECK (length(CAST(label AS BLOB)) BETWEEN 1 AND 128),
taxonomy_id TEXT CHECK (taxonomy_id IS NULL OR length(taxonomy_id) BETWEEN 1 AND 64),
confidence REAL NOT NULL CHECK (confidence >= 0.0 AND confidence <= 1.0),
classified_at_ms INTEGER NOT NULL CHECK (classified_at_ms >= 0),
input_asset_uuid TEXT NOT NULL CHECK (length(input_asset_uuid) = 36),
input_revision INTEGER NOT NULL CHECK (input_revision >= 1),
kind TEXT NOT NULL CHECK (kind IN ('automated', 'owner_confirmed')),
annotation_revision INTEGER NOT NULL DEFAULT 1 CHECK (annotation_revision >= 1),
created_at_ms INTEGER NOT NULL,
updated_at_ms INTEGER NOT NULL,
PRIMARY KEY (observation_uuid, key),
-- The key's namespace segment is the writer's namespace.
CHECK (substr(key, 1, length(writer_namespace) + 1) = writer_namespace || '/')
);

-- New persisted action-mask positions are appended; existing positions never
-- move. Built-in roles fail closed: only Administrator receives the action.
INSERT INTO authz_actions
(action_key, category, description, camera_scoped, destructive, sort_order, bit_index)
VALUES
('observations.annotate', 'Integrations',
'Annotate detection observations with classifier results', 1, 0, 200, 19);

INSERT INTO authz_role_actions (role_uuid, action_key)
SELECT '00000000-0000-4000-8000-000000000001', action_key
FROM authz_actions
WHERE action_key = 'observations.annotate';

-- migrate:down

-- Drops all classification annotations; take a backup first.
DELETE FROM authz_role_actions WHERE action_key = 'observations.annotate';
DELETE FROM authz_actions WHERE action_key = 'observations.annotate';
DROP TABLE IF EXISTS observation_classifications;
41 changes: 41 additions & 0 deletions docs/DETECTION_ENGINES.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,44 @@ stream's legacy detection model empty); see the runtime boundaries below.
- Engine lifecycle still follows the stream's existing
`detection_based_recording`/schedule lifecycle. This release does not create
a separate always-on analytics-only coordinator or UI editor.

## Observation evidence (INT_01)

A camera can keep durable observations for integrations such as YA-WAMF: one
row per detected object, with its label, confidence and box, plus the image
the detector ran on. It is **off by default**. Turn it on per camera with
`observation_evidence_enabled`, and bound it with `observation_labels`,
`observation_max_age_days` and `observation_max_bytes` on the stream API
(`camera.configure`), or in the camera's **Observation evidence for
integrations** settings. Changes apply without a restart. Defaults and bounds
are in the
[observations contract](integrations/OBSERVATIONS_CONTRACT.md#13-budgets-and-configuration).

What each engine contributes:

| Engine | Observations | Evidence image |
| --- | --- | --- |
| `object` (SOD) | one per eligible object | the decoded detection frame; transform `resize` to the network size |
| `object` (TFLite on LiteRT) | one per eligible object | the decoded detection frame; transform `letterbox` |
| `api` (single HTTP detector) | one per eligible object that passes zones, object filters and the threshold | the exact JPEG submitted to the detector; transform `none`, or a server-declared letterbox |
| `onvif` | one per classified camera event (not plain `motion`) | none: `unavailable(no_matching_media)`, no box |
| `motion` | none | none |
| Additional `api` engines in a multi-engine set | none (they are isolated and do not run) | none |

- The image is the low-resolution inference input, not a recording frame.
Boxes are normalized to it, and the `transform` records how it maps to the
source and inference sizes
([contract §4.4](integrations/OBSERVATIONS_CONTRACT.md#44-transform)).
- In a multi-engine set, each local object engine submits its own batch, so
each observation carries a single engine's provenance. Objects from one
frame share one image.
- Detection threads never block on evidence. A frame is copied into a bounded
queue and encoded by one worker thread; when the queue or a budget is full
the observation is recorded as `unavailable` with a reason (`queue_full`,
`budget_exhausted`) instead. If even the drop-record ring is full, the drop
is only counted (`untracked` in the diagnostics).
- Detection rows and their MQTT events are unchanged. The recording timeline
links a detection to the observation from the same camera, label and second
(±1 s) to show its classification.

The deployment recipe is [YA-WAMF with lightNVR](integrations/YA_WAMF.md).
61 changes: 61 additions & 0 deletions docs/EVENT_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ Every event uses a CloudEvents-inspired JSON envelope:
| Type | Severity | Sensitivity | Default expiry | Expected rate | Media policy |
| --- | --- | --- | ---: | --- | --- |
| `io.lightnvr.detection.object.v1` | info | operational | 1 hour | high | reference allowed |
| `io.lightnvr.detection.observation_changed.v1` | info | operational | 1 hour | high | reference allowed |
| `io.lightnvr.recognition.license_plate.v1` | info | restricted | 1 hour | high | forbidden |
| `io.lightnvr.camera.offline.v1` | warning | operational | 1 day | low | forbidden |
| `io.lightnvr.camera.recovered.v1` | info | operational | 1 day | low | forbidden |
Expand All @@ -62,6 +63,34 @@ Expiry is internal delivery metadata and is intentionally not part of the JSON
payload. MQTT 5 delivery may carry it as a message-expiry property; the durable
outbox uses the same value.

### Observation change events (INT_01)

`io.lightnvr.detection.observation_changed.v1` (subject `camera/<uuid>`) is
defined by the
[observations contract](integrations/OBSERVATIONS_CONTRACT.md#6-change-event-iolightnvrdetectionobservation_changedv1)
and registered in `src/core/event_envelope.c`. Unlike the facts above, each
message carries a deterministic UUIDv5 `id` per observation revision and its
`time` is the change row's creation time truncated to seconds, so a replay
is byte-identical.

- The publication relay (`src/core/observation_relay.c`) enqueues staged
changes in persistence order through `event_producer_enqueue_durable()`,
the same router → outbox path as other durable producers. Route
suppression applies; a suppressed or unrouted change is recorded as
`relayed_no_route`, never deleted.
- The type holds at most 2,000 undelivered outbox rows (contract D8). Above
that the relay pauses and leaves changes pending, so a broker outage cannot
crowd out operational warnings.
- Delivery is at least once. Consumers deduplicate on `source` + `id`, and
the
[observation history API](integrations/OBSERVATIONS_CONTRACT.md#7-history-and-cursor-get-apiobservations)
is the durable recovery path.
- Observations are recorded only for cameras with observation evidence turned
on (off by default). Setup for a consumer is described in the
[YA-WAMF deployment recipe](integrations/YA_WAMF.md).

Existing detection topics are unchanged.

## Data schemas

Additional fields are allowed unless they violate the privacy rules above.
Expand Down Expand Up @@ -96,6 +125,38 @@ metadata; when present, all four coordinates are required, normalized to
`0.0–1.0`, and width and height must be greater than zero. `track_id`, `zone_id`,
and an authorization-aware `snapshot_url` are optional.

### `io.lightnvr.detection.observation_changed.v1`

```json
{
"observation_id": "55555555-5555-4555-8555-555555555555",
"revision": 2,
"camera_uuid": "22222222-2222-4222-8222-222222222222",
"label": "bird",
"observed_at": "2026-11-08T14:03:12.481Z",
"change_cause": "evidence",
"evidence_state": "ready",
"evidence_reason": null,
"asset_id": "88888888-8888-4888-8888-888888888888",
"snapshot_url": "/api/observations/55555555-5555-4555-8555-555555555555/snapshot"
}
```

One message per observation revision. `change_cause` is `created`,
`evidence` or `classification`. `evidence_state` and `evidence_reason` are the
values at that revision, and `snapshot_url` is present only when the state is
`ready`. `camera_uuid` must equal the subject's UUID. Objects detected in the
same frame are separate observations that share `asset_id`. The payload is a
trigger: the box, transform and classifications come from
`GET /api/observations/{id}`. The full field rules are in
[contract §6.3](integrations/OBSERVATIONS_CONTRACT.md#63-data-payload), and
the OpenAPI schema is `ObservationChangeEvent`.

Event routes match this type by type and camera scope only. A route
`predicate` (`labels_any`, `zone_ids_any`, `min_confidence`) applies only to
`io.lightnvr.detection.object.v1`, so a route with a predicate never matches
observation events.

### `io.lightnvr.recognition.license_plate.v1`

```json
Expand Down
Loading
Loading