feat(vision): persist every recognized plate + snapshot as telemetry (non-blocking)

Answers "is a recognized plate saved?" — now yes, for both transient and subscriber, as
an ANPR audit trail independent of whether it matched anything.

VisionReader now stores the snapshot bytes in `snapshots` keyed by identity=PLATE — the
same identity the flow signs its anomaly/event with — so GET /api/snapshots/by-identity/:plate
(the booth event-detail modal's snapshot strip) shows the car's photo against that
anomaly with no UI changes. It also records an unsigned device_events{kind:"read"}
breadcrumb (plate, confidence, region, model, snapshotId, and the dispatch outcome) as a
queryable recognition log. Switched from emitRead to calling ReadDispatcher.dispatch
directly (like qr-reader) to capture that outcome.

Non-blocking: a refused read (no session / unpaid / unknown plate) just returns
rejected — no barrier hold — and is logged with its snapshot for investigation. Plate
stays advisory (exit demands payment; subscription matches only a bound plate).

Verified e2e: a recognized AL plate with no open session signed exit.refused.noSession
(identity=plate), stored a 555KB snapshot under that plate, recorded the read breadcrumb
(accepted:false, reason "no open session"), and by-identity returned the image — the
refused read is fully investigable with its picture. Build + lint green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-19 16:29:10 +02:00
parent 7e086ff0d7
commit 540b333b06
4 changed files with 157 additions and 25 deletions
+20 -8
View File
@@ -173,14 +173,26 @@ now exists: **opt-in** (`VISION_ENABLED`, default off), **fail-soft** (any error
end-to-end against the live service (Node → `AA558EE` 0.999, `region=Albania`). (2) ✅ **DONE —
trigger wiring (`apps/server/src/vision-reader.ts`).** A **`VisionReader`** polls each **opt-in**
camera (`config.anpr === true`, off by default) every `VISION_POLL_MS`, captures a snapshot →
`VisionClient.analyze` → on a **confident** plate emits a `DeviceReadEvent{kind:"plate"}` onto the
**same read bus a physical reader uses** (`deviceEvents.emitRead`), so the `ReadDispatcher` routes it
to the subscription/exit flow **unchanged**. Guards: low-confidence reads are dropped (not an
identity); a **debounce** (`VISION_DEDUPE_MS`) stops the same plate re-firing while a car sits in
frame; an in-flight guard prevents overlapping recognizes; idle when vision is off or no camera opts
in. Verified end-to-end (live service → reader → one `AA558EE` read on the bus; debounce held it to 1
emit over 7 polls). Plate stays **advisory** — the exit flow still demands a `payment`, the
subscription flow only matches a **bound** plate. (3) **field-accuracy** unknown — re-benchmark/tune
`VisionClient.analyze` → on a **confident** plate dispatches a `DeviceReadEvent{kind:"plate"}` through
the **same `ReadDispatcher` a physical reader uses** (called directly to capture the outcome, like
`qr-reader.ts`), so the subscription/exit flow consumes it **unchanged**. Guards: low-confidence reads
are dropped (not an identity); a **debounce** (`VISION_DEDUPE_MS`) stops the same plate re-firing while
a car sits in frame; an in-flight guard prevents overlapping recognizes; idle when vision is off or no
camera opts in. Plate stays **advisory + non-blocking** — the exit flow still demands a `payment`, the
subscription flow only matches a **bound** plate, and a refused read never holds a barrier.
**Every confident read is PERSISTED (the ANPR audit trail, so a read is investigable):** the
**snapshot bytes** are stored in `snapshots` keyed by **`identity = plate`** — the SAME identity the
flow signs its anomaly/event with — so `GET /api/snapshots/by-identity/:plate` (the booth event-detail
modal's snapshot strip) shows the car's photo **against that anomaly with no extra wiring**; plus an
unsigned **`device_events{kind:"read"}`** breadcrumb records plate / confidence / region / model /
`snapshotId` / the **dispatch outcome** (`accepted` + `reason`) — a queryable log of every recognition
and whether it matched, separate from the signed ledger. *Verified end-to-end:* a recognized AL plate
with no open session was non-blocking → signed a `exit.refused.noSession` anomaly (identity=plate),
stored a 555 KB snapshot under that plate, recorded the read breadcrumb with
`accepted:false, reason:"…no open session…"`, and `by-identity` returned the image — i.e. the refused
read is fully investigable with its picture. Debounce held a re-seen plate to 1 emit over 7 polls.
(3) **field-accuracy** unknown — re-benchmark/tune
the threshold on real on-site captures (angle/night/dirt). (4) the **weight-provenance** check (open).
**Bottom line: consume it as a gated advisory identity source feeding the existing `kind:"plate"` path
— not as sole authority — and Job 2 is still required for the anti-spoofing value.** The
+4
View File
@@ -932,3 +932,7 @@ Scaffolded the Node-side adapter to the host vision microservice per the fitness
## [2026-06-19] feat | VisionReader — wire ANPR into the read bus (apps/server/src/vision-reader.ts)
Wired the vision service into the entry/exit flows via the READ BUS. VisionReader polls each OPT-IN camera (config.anpr===true, off by default) every VISION_POLL_MS, captures a snapshot → VisionClient.analyze → on a CONFIDENT plate calls deviceEvents.emitRead({kind:"plate", value:PLATE, deviceId, driverId}) — the SAME event a physical plate reader emits, so the existing ReadDispatcher routes it to the subscription/exit flow UNCHANGED (no flow rewrite). The plate stays ADVISORY by construction: the exit flow still demands a covering payment (a plate can't bypass it), the subscription flow only matches a BOUND plate (subscriptionPlates). Guards: low-confidence reads DROPPED (a shaky read isn't an identity); DEBOUNCE (VISION_DEDUPE_MS, default 15s) so a parked car in frame doesn't re-fire the same plate; per-camera in-flight guard; idle when VISION_ENABLED off or no camera opts in; #recognizeOn is public for a future on-demand trigger (loop edge / API). Direction from directionOf (both→entry context). Constructed in server.ts, start on onReady / stop on onClose. VERIFIED END-TO-END: in-memory anpr camera returning the AL plate image + live fast_alpr service → VisionReader emitted exactly one {kind:"plate",value:"AA558EE"} read onto the bus; debounce held it to 1 emit over 7 polls. Build+lint green. Updated [[opencv-anpr-service]] (trigger-wiring gap + per-camera opt-in marked done). Remaining: SetupWizard anpr toggle, field tuning, weight-provenance, Job 2.
## [2026-06-19] feat | Persist every recognized plate + snapshot (ANPR audit trail, non-blocking)
VisionReader now PERSISTS every confident plate read so a recognition is investigable — and switched from emitRead to calling ReadDispatcher.dispatch directly (like qr-reader) to capture the OUTCOME. On a confident plate it: (1) stores the SNAPSHOT bytes in `snapshots` keyed by identity=PLATE — the SAME identity the flow signs its anomaly/event with — so GET /api/snapshots/by-identity/:plate (the booth event-detail modal's SnapshotStrip, which already uses e.identity) shows the car's photo against that anomaly with ZERO UI changes; (2) records an unsigned device_events{kind:"read"} breadcrumb with plate/confidence/region/modelVersion/snapshotId + the dispatch outcome (accepted + reason) = a queryable ANPR log independent of the signed ledger; (3) dispatches the read — NON-BLOCKING: a refused read just returns rejected (no barrier hold), logged with its snapshot for investigation. Plate stays advisory (exit demands payment; subscription matches only a bound plate). VERIFIED e2e: a recognized AL plate (AA558EE) with no open session → signed exit.refused.noSession anomaly (identity=plate), stored a 555KB snapshot under that plate, read-breadcrumb accepted:false reason:"no open session", and by-identity returned the image (status 200, 1 snapshot) → the refused read is investigable with its picture. Build+lint green. VisionReader constructor now takes the ReadDispatcher (wired in server.ts). Answers "is a recognized plate saved?" — now YES for both transient + subscriber, as telemetry + evidence image, regardless of match. Updated [[opencv-anpr-service]].