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