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
+1 -1
View File
@@ -177,7 +177,7 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
// reader uses → the dispatcher routes it to the subscription/exit flow unchanged. A
// plate stays advisory: the exit flow still demands a payment, the subscription flow
// only matches a BOUND plate. Idle when vision is disabled or no camera opts in.
const visionReader = new VisionReader(db, visionClient, app.log);
const visionReader = new VisionReader(db, visionClient, readDispatcher, app.log);
app.addHook("onReady", async () => visionReader.start());
app.addHook("onClose", async () => visionReader.stop());