Skip to content

INT_01: durable object observations with evidence and classification write-back; WebRTC External IP list; clearer ONVIF test errors - #933

Open
matteius wants to merge 65 commits into
mainfrom
feat/int01-event-evidence
Open

matteius wants to merge 65 commits into
mainfrom
feat/int01-event-evidence

Conversation

@matteius

@matteius matteius commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

lightNVR now keeps a durable record of each object detection, with a stored photo, and delivers it over MQTT at least once. That delivery survives broker outages and restarts. A client can also write its own classification back onto the detection.

This works for any detector label. Each camera's observation_labels setting picks the labels to keep (for example person,car,package), and an empty list keeps every label. The API and events have no bird-specific fields. Any client with the observations.annotate permission can write classifications under its own namespace.

YA-WAMF is the first consumer and the integration we tested end to end: it picks the bird observations, identifies the species, and writes it back as yawamf/species. The PRD for that integration is docs/prd/INT_01_Event_Enrichment_YAWAMF.md, and the contract is docs/integrations/OBSERVATIONS_CONTRACT.md.

The matching YA-WAMF change lives on feature/lightnvr-provider in a separate repo and isn't part of this PR.

What it adds

  • Durable observations. Migrations 0095 and 0096 add the tables. Every change is logged in the same transaction as the write, and a relay publishes the log through the existing outbox. Message IDs are deterministic.
  • Evidence photos. Detection threads hand the inference frame for each eligible label to a bounded queue without blocking. A worker encodes the JPEG off-thread. Each photo is pending, ready, unavailable or expired, and retention runs per camera.
  • Detectors covered. Local detectors, HTTP API detectors and ONVIF events (ONVIF observations have no photo). Startup cleanup removes leftover partial writes.
  • API. Observations, cursor-based history, evidence download, capabilities, and PUT /api/observations/{id}/classifications/{namespace}/{name}. The PUT needs the new observations.annotate permission. A cursor that has gone stale, or that came from before a database restore, gets 410 cursor_expired.
  • Settings, diagnostics and UI. Evidence is opt-in per camera, with per-camera budgets. Pipeline health appears in metrics and system info. Detection results show the linked classification.
  • Docs. docs/integrations/YA_WAMF.md has the deployment recipe and the supported-version matrix.

Testing

  • Unit tests: new tests with ASan/UBSan builds for each module.
  • Contract and crash tests: a contract and crash-boundary suite, plus Playwright specs for history, reliability and the pipeline (L11).
  • End-to-end: tests/integration/yawamf_e2e/ runs real Mosquitto, lightNVR and YA-WAMF as host processes. All 10 scenarios pass (A1–A8, A12, A13, A7-annotate):
    • publish and admit an observation;
    • check that the photo is the frame the detector saw;
    • handle two birds in one frame;
    • replay without creating duplicates;
    • survive the broker down for 100 observations;
    • recover after each process is killed mid-burst;
    • expire evidence and recover from a database restore;
    • write the species back, with owner corrections locked.
  • Performance gate (A11): results are in tests/performance/observations/results/.
    • Seven 600 s runs: 3 with evidence off, 2 at the default limits, and 2 with the queue deliberately saturated.
    • No recording gaps in any run. Segment-interval p95 changed by less than 0.1%.
    • Saturation drops all carry a reason.
    • Submit p99 is at most 50 µs. A rejected frame was blocking the detection thread for 1.5–2.6 ms; 9ed02bb4 fixes that.
    • The budgets are now final.

Also in this PR

  • WebRTC External IP list (3ddcc03b).
    • The go2rtc External IP setting now takes up to 8 entries, separated by commas, semicolons or whitespace. Each entry becomes its own WebRTC candidate: an IP or hostname with an optional :port, IPv6, * or auto for auto-detect, or stun:host[:port].
    • Previously the setting held one address, so a user reaching the server over both the LAN and Tailscale only got WebRTC and two-way audio on one of those paths; the other fell back to MSE.
    • An invalid list is rejected with a 400 instead of producing a broken or YAML-breaking candidate.
  • ONVIF connection test (1c0d0712).
    • POST /api/onvif/device/test used to return a generic 500 for every failure. It now returns a 502 that says what failed: the camera rejected the credentials (HTTP 401/403 or a NotAuthorized SOAP fault), the ONVIF service couldn't be reached, the camera returned no usable profile, or the stream couldn't be opened. Credentials are removed from the message.
    • Some cameras advertise an rtsps:// stream address, for example on port 322, while secure RTSP is turned off. When such an address can't be opened, the test now retries plain rtsp:// on port 554 with the same path.
  • Review fixes:
    • Atomic bool flags in the new code are now atomic_int, so the C-to-Rust conversion builds.
    • The CodeQL time-of-check alert in the evidence startup cleanup is fixed by checking and deleting relative to the open directory.
    • The end-to-end harness now closes its log files.
    • The evidence settings text no longer reads as bird-only.

Known gaps and follow-ups

  • Database lock latency. The DB-lock probe is advisory in the gate, because two evidence-off runs differ by up to 5% on it. Pooled across all runs, its p95 rises about 10% with evidence on, while the median is unchanged. The likely cause is SQLite checkpoints running while the global lock is held. The fix is to checkpoint outside the lock or batch the worker's commits; that's a follow-up.
  • Detection↔observation match. The link between a detection and its observation is a heuristic. Two objects with the same label in the same second both link to the earlier observation.
  • Multi-engine API detectors. Those engines never run, so they produce no evidence.
  • Not yet run:
    • the Docker mode of the end-to-end suite, in CI;
    • a real HTTP or TFLite bird model;
    • the A14 pilot on a real camera (tests/integration/yawamf_e2e/A14_PILOT_CHECKLIST.md).
  • Default permission. Only the Administrator role has observations.annotate by default.

Reviewing

  • rust/ isn't touched; CI regenerates it.
  • Most of the line count is generated OpenAPI output (web/generated/api/validators.js, include/web/openapi_document_generated.h) and committed performance results.
  • int01-event-enrichment-yawamf-plan.md holds the per-task logs.

🤖 Generated with Claude Code

matteius and others added 30 commits November 8, 2026 08:02
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add the normative observation/evidence/change-event/classification
contract that every lightNVR (L*) and YA-WAMF (W*) task implements:
field-by-field schemas, evidence states and bounded reasons, the
io.lightnvr.detection.observation_changed.v1 event, cursor encoding,
watermark and 410 rules including DB-restore epoch detection, the error
catalog, capability document, annotation namespace and owner
precedence, permission mapping, provisional budgets, reserved
identifiers (0095/0096, authz bit 19) and a PRD traceability table.

Point EVENT_CONTRACT.md at the reserved event type and link the PRD
status line to the contract.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add object_observations, observation_assets, observation_changes,
observation_publications and observation_meta per the frozen
OBSERVATIONS_CONTRACT: change rows snapshot evidence_state/reason (D6),
publication state allows the five D7 values, and observation_meta holds
the DB-generation epoch and prune boundary (D9). Add per-camera evidence
columns on streams and the global history/queue settings, with a full
migrate:down. Regenerate the embedded migration header.

test_observation_migration covers the fresh schema, constraint
rejections, recording delete detaching observations, and
0094 -> 0095 -> down -> up schema equivalence with a fresh database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… (C2)

Add getIntegrationCapabilities, listObservations, getObservation,
getObservationSnapshot and putObservationClassification to the canonical
OpenAPI document with the frozen authorization mapping (contract §12.1),
plus the Observation*, IntegrationCapabilities and ObservationError
schemas. The PUT key is two path segments, {namespace}/{name}; it is
authenticated-only until L13 adds observations.annotate to the catalog.

Register all five routes as authenticated 501 not_implemented stubs in
src/web/api_handlers_observations.c so the coverage gate holds, and add
scripts/contracts/coverage.mjs to refresh coverage.json (line numbers,
handlers, sha256, and new routes via --add-coverage).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… (C4)

Add tests/performance/observations: a repeatable workload harness (N looped
RTSP streams via standalone go2rtc, continuous recording, local SOD detector
at a fixed interval), pure metrics/gate helpers with unit tests, a compare
CLI for C4 repeatability and the X2 release gate, and two feature-disabled
baseline runs with host description and raw samples.

fixture-server gains an optional extraConfig INI suffix so X2 can enable
evidence budgets without editing the harness.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Canonical fixtures for the observations contract in
tests/fixtures/integrations/observations/, generated from one coherent
scenario by scripts/contracts/observation-fixtures.mjs: observations in
every evidence state, two birds sharing an asset, a letterboxed HTTP
transform, full MQTT envelopes for created/evidence/classification changes
with UUIDv5 message IDs (contract 5.4 test vector included), chained
history pages, error bodies, capability documents, classification PUT
exchanges (201, no-op retry, 409 revision_conflict, 409
owner_decision_locked, 422 evidence_mismatch, 403 namespace_forbidden) and
a 160x80 evidence JPEG with a box manifest.

observation-fixtures.test.mjs validates every fixture against the C2
OpenAPI schemas (Ajv oracle plus generated validators), rejects malformed
variants, recomputes message IDs, cursors, pages and box math, and checks
the YA-WAMF vendored copy when present.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds db_observations: idempotent batch inserts, compare-and-set evidence
transitions, the contract section 7.3 cursor page algorithm, publication
staging for the L5 relay and history pruning. Every mutation is one
BEGIN IMMEDIATE transaction under the DB mutex (db_auth.c pattern) that
writes the observation/asset rows, change rows (with the evidence state
at that revision) and pending publications with deterministic UUIDv5
message IDs together. Reads use a private read-only WAL snapshot.

Also adds base64url encode/strict decode, a UUIDv5 helper (mbedTLS
SHA-1) for L5 to reuse, and loads/saves the migration 0095 per-camera
evidence columns on stream_config_t, normalizing unset budgets to the
contract defaults so zeroed configs never violate the CHECKs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…T_01 L4)

Startup reconciliation (contract section 14), run from main.c before the
evidence worker and relay start:
- D9 cursor-head sidecar check first, before any transition advances the
  head: same epoch with DB head < sidecar head regenerates the epoch; a
  missing or malformed sidecar is recreated.
- Delete *.jpg.tmp and every <uuid>.jpg that no ready asset row points at
  (orphans, unfinished pending writes, stale expired/unavailable media).
- pending -> unavailable(interrupted); ready with a missing file ->
  unavailable(write_failed), all through the L2 CAS transitions so every
  referencing observation gets an evidence revision and publication.

Periodic bounded pass from the storage manager heartbeat (rate-limited to
5 min unless a bound was hit; no-op until startup reconciliation ran):
- history prune past observation_history_retention_days (advances
  observation_meta.oldest_seq);
- reclaim assets left without observations (expire, unlink, delete row);
- per camera: expire unprotected ready media past the age budget, using
  the newest reference so shared assets survive until their last
  reference expires, then evict oldest unprotected media until within the
  byte budget. Pending assets are never touched. Expiry keeps metadata.
- budget-exhausted flag when over budget with only protected media left;
  exposed via observation_retention_budget_exhausted() and pushed to L3
  through a weak observation_evidence_set_budget_exhausted() reference.

Only files resolved inside <storage>/observations are ever deleted, so
recordings are never touched. db_core.c regenerates the observation
cursor epoch after either restore_database_from_backup() call succeeds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…1 L5)

Register io.lightnvr.detection.observation_changed.v1 (info, operational,
1 h expiry, camera subject) with a section 6.3 payload validator, and add
the relay that turns staged observation_publications rows into envelopes.

- event_producer_enqueue_durable() exports the router -> outbox path with
  an enqueued/duplicate/no_route/mqtt_disabled/full/failed result. The
  existing static producers keep their 0/-1 behavior.
- The relay wakes on a post-commit hook from the DB layer (or
  observation_relay_notify()) plus a 1 s poll, reads bounded seq-ordered
  batches, marks relayed / relayed_no_route only after the outbox step,
  then publishes on the in-process bus. FULL or an error leaves the row
  pending and backs off; expired rows become expired and invalid rows
  failed. It drains synchronously (bounded) at startup.
- D8: pauses while 2,000 undelivered outbox rows of the type exist.
- D9: writes <storage>/observations/.cursor_head.json (at most 1/s) and
  regenerates the epoch at startup when the sidecar shows a rewound head.
- Stats getter for L10. Started after L4 reconciliation in main.c and
  stopped before the event bus on both shutdown paths.

Tests: test_observation_relay (crash drain, duplicate, no route, route
suppression, cap, error backoff, expiry, notify, sidecar, C3 fixture
byte equality) and validator cases in test_event_envelope.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… L3)

Producers hand an owned RGB24 frame (or the exact submitted JPEG bytes for
the HTTP detector) plus the observation batch to
observation_evidence_submit*(). Admission is lock-free with respect to the
worker: atomic count/byte reservations (global and per camera), a Treiber
stack for items, a preallocated Vyukov ring for drop records and sem_post
wakeups. It never touches the DB or blocks on I/O.

The single worker inserts the batch (asset pending), encodes with the shared
jpeg_encoder_cache code, writes <asset>.jpg.tmp, fsyncs, renames, fsyncs the
directory and only then commits pending -> ready. Failures unlink the temp
file and record unavailable(encode_failed|write_failed). queue_full and
budget_exhausted drops are inserted as unavailable(<reason>);
dropped_untracked counts drops lost when the ring is full.

The worker and ring start lazily on the first submission, so nothing is
allocated while evidence is disabled. Shutdown drains for a bounded window
and records the rest as unavailable(interrupted). main.c initializes it
between L4 reconciliation and the L5 relay and stops it after the relay.

Files live at <storage>/observations/<camera_uuid>/<yyyy-mm-dd UTC>/<asset_uuid>.jpg
(path stored relative to the storage root).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Replace the C2 501 stubs for the four GET operations (the P2 PUT stays 501
until L14):

- GET /api/integrations/capabilities: installation source from
  event_identity, contract versions, per-principal operations
  (history/snapshot authorized when live.view/snapshot.create is held on any
  camera; annotate unsupported until L14), limits, history retention and
  oldest cursor/observed_at, MQTT delivery topic, the D3 writer namespace
  (normalized username, reserved names -> null) and up to 1,024 cameras with
  the observation actions held.
- GET /api/observations: D1 comma lists (<=10 cameras, <=8 labels),
  since/until RFC 3339, limit 1..500, cursor/head. The scope is the
  intersection with cameras where the principal holds live.view; an explicit
  unauthorized or unknown camera is 403. invalid_cursor 400, cursor_expired
  410 with oldest_cursor/oldest_observed_at.
- GET /api/observations/{id}: live.view on the capture-time camera via
  httpd_evaluate_camera_identity_action; unknown, deleted-camera and
  unauthorized IDs are byte-identical 404 observation_not_found.
- GET /api/observations/{id}/snapshot: ready streams only this asset's file
  (image/jpeg, Cache-Control private max-age=300, ETag "<asset_id>", 304 on
  If-None-Match); pending 202 + Retry-After; unavailable 404 with reason;
  expired 410 with reason/expired_at; D4 403 for live.view without
  snapshot.create, 404 otherwise. Never substitutes another frame; a ready
  row whose file is gone is 404 evidence_unavailable(write_failed), and a
  stored path outside <storage>/observations is refused. Each fetch is
  audited as snapshot.create on target_type observation (camera, asset,
  state, outcome), honouring the allowed-read decision mode.

observation_record_to_json() follows the C3 fixture conventions (pending
nulls size/expiry; never-had-media nulls every media field; no path), and
is exported for L14 together with the time and namespace helpers.

OpenAPI: drop the 501 response from the four GET operations and regenerate
the projections; observations-contract.test.mjs now expects 501 only on PUT.

Tests: test_api_handlers_observations runs the handlers against a migrated
DB with wrapped authz/serve/audit: every status code, authorization
intersection, D4, path containment, and exact equality with the C3
observation and error fixtures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…T_01 L9)

Expose observation_evidence_enabled, observation_labels,
observation_max_age_days and observation_max_bytes on stream GET (list,
detail, full) and accept them on POST/PUT (camera.configure). The handlers
enforce the contract section 13 bounds that migration 0095 does not: a
boolean flag, <= 16 trimmed labels of 1..31 printable ASCII bytes (empty
means every label), 1..90 days and 64 MiB..1 TiB. Invalid values return
400 before any work is queued; changes never restart the stream.

OpenAPI documents the fields (optional on read for additive compatibility)
and the generated projections are refreshed. StreamConfigModal gains an
"Observation evidence for integrations" section, off by default, noting a
bird-capable object detector is required.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…1 L6)

Hand each accepted SOD/TFLite/multi-engine inference result to the L3
evidence queue while the decoded RGB24 frame is still alive:

- detect_on_decoded_frame keeps the embedded-model inference buffer past
  zone/object/threshold filtering and submits it with the surviving,
  label-eligible detections (one batch_id per accepted result).
- detect_with_runtime_engines records each engine's slice of the combined
  result and submits one batch per object engine (padded copy for all but
  the last); motion results never become observations, API engines stay
  out (L7).
- The detection-stream thread captures on the inference thread, so the
  asset and transform use the detection stream's resolution.
- New sidecar include/video/detection_evidence.h / src/video/
  detection_evidence.c: batch building (engine provenance, model basename,
  clamped boxes, confidence clamp, track only for track_id >= 0 with
  namespace = engine key and session = per-process boot UUID),
  case-insensitive eligible-label matching, transform (SOD resize to the
  network size via new sod_model_input_size(), LiteRT letterbox mirroring
  letterbox_rgb24()), and the owned-buffer capture.
- Evidence settings are cached in the UDT context at start and refreshed
  by unified_detection_set_observation_settings(), called from the PUT
  stream worker; no DB access on the detection/packet threads.

detection_result_t, store_detections_in_db_for_camera and
event_producer_publish_detection are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
matteius and others added 17 commits November 8, 2026 09:55
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…(INT_01 L15)

GET /api/detection/results/{name} now links each detection to the
observation captured by the same, already-authorized camera in the same
second +/-1 with the same label (ASCII case-insensitive, nearest second,
then earliest, then UUID), using idx_object_observations_camera_time on
one read-only connection. A linked detection gains an optional, closed
"observation" member {id, owner_confirmed, classification}, where
classification is owner_confirmed[0] before automated[0] or null. All
existing fields are unchanged; unlinked detections carry no member.

The timeline player and the recording video modal list each linked
observation with the detector label and confidence, the classified
label, confidence and classifier, an owner-confirmed badge, and a link
to /api/observations/{id}. The canvas overlay label appends the
classification to the detector result.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…T_01 L11)

Playwright API contracts against the native binary:
- observationsHistory: A7 camera-scoped isolation on list/get/snapshot for
  policies and scoped tokens; rename, delete and name reuse never transfer
  access; A8 distinct pending/expired/unavailable/unknown/expired-cursor
  outcomes; A5 cursor pagination with pending->ready recovery.
- observationsReliability (MQTT): SIGKILL then restart drains committed
  changes in reconcile -> evidence -> relay order; relayed-but-unacked
  changes are redelivered under the same message IDs; DB restore turns old
  cursors into 410; broker down with 100 observations recoverable via the
  outbox and the cursor.
- observationsPipeline (MQTT): a real HTTP detection stream produces two
  sibling observations sharing the exact inference JPEG, one message ID per
  revision.

test_observation_crash_boundaries SIGKILLs a forked pipeline at six
persistence boundaries and restarts it, asserting no ready asset points at
an unfinished file and every committed change reaches the outbox exactly
once. The kill points are stage hooks reachable only from linked code (no
config, environment or API path). The fixture server gains kill/relaunch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…bservation_pipeline (INT_01 L12)

- New docs/integrations/YA_WAMF.md: bird-capable detector, per-camera
  evidence opt-in and budgets, MQTT prefix and event routes (no label
  predicate), scoped user/token with live.view, snapshot.create and
  observations.annotate, YA-WAMF settings, verification, P2 write-back,
  retention and cursor expiry, diagnostics, delivery guarantees, rollback
  and the supported-version matrix.
- EVENT_CONTRACT, MQTT_INTEGRATION and DETECTION_ENGINES describe the
  observation event, its routing rule and which engines produce evidence.
- PRD status and PRD index updated; contract section 18 points at the
  published matrix.
- OpenAPI (L10 follow-up): SystemInformation gains an optional
  observation_pipeline (ObservationPipelineDiagnostics) mirroring
  observation_diagnostics_to_json(); projections regenerated. The web
  type's failures map is widened to match the generated type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cklist (INT_01 X1)

tests/integration/yawamf_e2e runs docs/integrations/YA_WAMF.md step by
step against real processes: Mosquitto 2.0.22, the lightNVR binary, and
the YA-WAMF backend from feature/lightnvr-provider. A pinned launcher
replaces only classify_async_live, the feeder is an ffmpeg HTTP MPEG-TS
source, and a loopback HTTP detector finds the fixture birds by colour.
It then checks A1, A2 (HTTP, plus an optional local SOD run on a real
bird photo), A3, A4 (10x replay overlapping backfill), A5 (broker down
for 100 observations, both apps restarted), A6 (SIGKILL of lightNVR and
of YA-WAMF mid-burst) and A8 (temporary, expired, unavailable, and an
expired cursor after a DB restore). Host-process mode was executed and
every scenario passed (results/x1-host-20261108.json). The
docker-compose container mode is committed as the documented CI path
but was not run.

Recipe corrections:
- stream names cannot contain spaces;
- PUT /api/streams answers 202 and applies asynchronously;
- role and policy writes need expected_policy_version;
- detection_threshold in the stream API is a percentage;
- YA-WAMF needs a fixed build to start with LIGHTNVR__CAMERA_UUIDS set.

The supported-version matrix now records what X1 ran and how. A
real HTTP or LiteRT bird model, YA-WAMF's real classifier and A14 stay
pending.

The run found three YA-WAMF bugs, fixed on its branch (b89846d0,
fcf449f2, 37c0877f + 1cbbae74). The pre-fix A6 failure is kept as
results/x1-host-20261108-prefix-a6.json.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tate) (INT_01 X3)

Extend the X1 host-process suite with p2_scenarios.py. YA-WAMF now starts
with LIGHTNVR__WRITEBACK_ENABLED=true (--no-writeback for the P1 recipe only).

- A12: the species appears on the YA-WAMF row, in observation_classifications,
  GET /api/observations/{id} and the detection-results enrichment; the
  classification change (revision 3) triggers no classifier call, intent
  change or new write-back job; replays, identical retries and stale/future
  revisions keep one annotation; an owner correction arrives as
  owner_confirmed and a later automated result is refused on both sides.
- A13: a lightNVR outage during write-back leaves the YA-WAMF detection intact
  and "sync pending"; the write-back completes after lightNVR returns.
- A7-annotate: no annotate action gives 403, another camera's scope 404, and
  neither writes; the annotate token creates and retries cleanly.

Full run (X1 + X3, 454 s) passes against YA-WAMF 1cbbae74; the RED run with
write-back off is kept. Supported-version matrix and contract section 18
updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tency gate (INT_01 X2)

X2's saturation run found that a dropped 720p evidence submit spent
1.5-2.6 ms on the detection thread inside free() of the 2.7 MB RGB frame
(an munmap on a busy, many-threaded process), breaking the L3 "never
blocks, < 1 ms" admission contract. Tracked drops now hand the frame to
the worker through the drop record; pending deferred frames are bounded by
max(global queue bytes, 32 MiB) and fall back to an inline free past that
bound or when the drop ring is full.

Observability for the A11 gate:
- lightnvr_observation_submit_latency_seconds histogram (+ max gauge) for
  every admission that reaches the queue;
- configured limits (items/bytes, global/camera/discard), the per-camera
  high-water mark and deferred/inline discard counters.

Harness (tests/performance/observations):
- run-workload.mjs: --system-setting K=V (restart once), L10 feature
  metrics scrape, post-shutdown DB summary (observations, assets by
  state:reason, detections);
- metrics.mjs: histogram helpers, observationFeatureSummary,
  assetReasonSummary, evidenceGate (evidence happened, limits held, drops
  carry reasons, no failures, submit < 1 ms, optional saturation);
- compare.mjs gate: same binary required, evidence checks for enabled runs;
- make-bird-clip.sh: detection-producing clip recipe (photo not committed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lize C1 budgets (INT_01 X2)

Seven 600 s runs of the C4 fixture on 9ed02bb with a bird clip (every
inference detects): 3 disabled, 2 enabled at defaults, 2 enabled with a
4 MiB queue (56% queue_full). All four disabled/enabled gates pass:
0 recording gaps, segment-interval p95 -0.004%..+0.04%, evidence and DB
observations present, limits held, every drop recorded with its reason
(0 untracked), no failures, producer submit max <= 405 us (p99 <= 50 us).

The DB-mutex probe fails its own 2% repeatability (2.4-5.0% between
disabled runs) and is advisory under C4's rule; its pooled p95 rises
+9.9% (defaults) / +6.5% (saturated) with an unchanged median, recorded
as a finding (likely WAL autocheckpoints under the global DB mutex).

compare.mjs: --advisory KEY and a pool command (pooledComparison).
Contract §13 budgets are final with their X2 basis; §19 records the
result; YA_WAMF.md, DETECTION_ENGINES.md and the scan-cap comment no
longer call them provisional.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@matteius matteius changed the title INT_01: durable bird observations with evidence for YA-WAMF INT_01: durable object observations with evidence and classification write-back (first consumer: YA-WAMF) Nov 8, 2026
Comment thread src/core/observation_retention.c Fixed
@codecov

codecov Bot commented Nov 8, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

matteius and others added 4 commits November 8, 2026 15:35
…nce UI text

The detection-thread observation_evidence_enabled flag was an atomic_bool,
which the C->Rust conversion turns into atomic_load/atomic_store on bool
(rustc E0511). Use atomic_int 0/1 like the rest of the context struct.

The per-camera evidence settings now describe the feature as working for
any detector label instead of reading as bird-only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…CTOU)

The startup evidence sweep checked each entry with lstat() and then opened,
removed or rmdir'ed it by path, so an entry swapped for a symlink in between
could redirect the action outside the evidence root (CodeQL
cpp/toctou-race-condition). Walk with fdopendir/fstatat(AT_SYMLINK_NOFOLLOW)
and act with openat(O_NOFOLLOW)/unlinkat on the directory fd, as
hls_cache_cleanup.c does. The configured root itself is still followed.

Tests pin the behaviour around it: nothing below the root is followed,
and a symlinked root is still swept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…st conversion

The C->Rust conversion turns atomic_bool loads, stores and exchanges into
intrinsics that reject bool (rustc E0511). The existing atomic_bool sites are
patched per file in tools/rust-transpile/repairs.py; the new INT_01 flags
(evidence queue start/stop/worker state, per-camera budget_exhausted, and the
retention startup latch) use atomic_int 0/1 instead, so they need no repair
entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Generated by tools/rust-transpile/generate.py for 2d24bdd.
matteius and others added 3 commits November 8, 2026 16:14
Address code-quality review on #933: the Mosquitto, lightNVR and YA-WAMF
launchers opened their log files inline in Popen and never closed the
parent's handle; open them in a with-block (the child keeps its own
descriptor). Remove the unused `checks` and the overwritten `started` in
run_e2e.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The External IP setting held one address and became one go2rtc candidate,
and setting it dropped the "*" auto-detect candidate. Users reaching the
NVR both over the LAN and over Tailscale/VPN could only get WebRTC (and
two-way audio) on one of those paths; the other fell back to MSE.

The setting is now a list (commas, semicolons or whitespace; up to 8
entries, 255 characters). Each entry becomes its own candidate:
IPv4/hostname[:port], IPv6 or [IPv6][:port], "*"/"auto" for auto-detect,
or stun:host[:port]. The settings API rejects an invalid list with 400
instead of writing a malformed or YAML-breaking candidate, and an invalid
saved value falls back to auto-detect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…RTSP

POST /api/onvif/device/test answered every failure with 500 "Failed to
connect to ONVIF device", whether the camera rejected the credentials, was
unreachable, or worked over ONVIF but refused the stream it advertised.

- test_onvif_connection_detail() classifies the failure (credentials
  rejected via HTTP 401/403 or a NotAuthorized SOAP fault, ONVIF service
  unreachable, no usable profile, or stream not openable) and builds a
  message with credentials and query strings removed. The handler returns
  it as 502, since the camera rather than this server failed.
- Some cameras advertise rtsps://host:322/... with secure RTSP disabled
  while plain RTSP on 554 works. When an advertised rtsps:// stream cannot
  be opened, the test now retries rtsp://host:554 with the same path.
- test_onvif_connection() keeps its 0 / non-zero contract for the stream
  create/update callers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@matteius matteius changed the title INT_01: durable object observations with evidence and classification write-back (first consumer: YA-WAMF) INT_01: durable object observations with evidence and classification write-back; WebRTC External IP list; clearer ONVIF test errors Nov 8, 2026
Generated by tools/rust-transpile/generate.py for 22e3fb8.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants