Files
parking_solution/wiki/concepts/vision-review-outbox.md
T
julian b485e9870b
CI / check (push) Failing after 40s
Build & push images / images (push) Failing after 32s
Build desktop / desktop (push) Successful in 5m24s
feat(collector): review collector skeleton — apps/collector, its own Komodo stack on the reviewer's host
The far end of the Car Wash review outbox (wiki/concepts/vision-review-outbox.md): a small
Fastify + SQLite service in the monorepo (shares the payload contract and the class
vocabulary via @parking/shared), delivered to art-docker-station by its own stack so
nothing booth-side lands there and nothing of it on a booth.

- POST /ingest: bearer token per booth (constant-time), X-Booth-Id must match, multipart
  meta + JPEG (magic checked, 2 MB cap), meta validated against the contract, idempotent on
  the item id; crop stored at crops/<booth>/<item>.jpg on the volume + one items row.
- /review + /api/*: the reviewer's screen served by the process (Basic auth, one login):
  one pending crop at a time, operator's pick and camera's pick beside it, one button/key
  per vocabulary class + unusable + skip; stats per booth and per hashed operator
  (agree / disagree / unusable — disagree = the reviewer's class is outside the operator's
  category).
- GET /export/labels.csv: reviewed usable rows for training; formula-leading cells are
  neutralised (booth-supplied names). Crops stay on the volume for the trainer on the host.
- Booth payload now carries operatorCategory.classes so the comparison needs no site setup.
- Delivery: apps/collector/Dockerfile (monorepo context), docker-compose.collector.yml
  (bind to the overlay IP; commented `trainer` profile seam for the GPU), a third build
  step in build-images.yml, a `wash-collector` stack in komodo/resources.toml with one
  secret per booth referenced from both the collector's token list and the booth's own
  stack (park-2 lines templated, commented, DNS name for the URL).
- Tests: app.test.ts (ingest ok/dup/refusals, review + stats + export, config). Image
  built and smoke-tested locally (health, ingest, duplicate, auth, verdict, export).

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-07 08:26:22 +02:00

99 lines
6.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: Vision review outbox — harvesting the operator's category choice for a trusted reviewer
type: concept
status: booth side built 2026-09-06; collector pending
related: [venue-modules, opencv-anpr-service, threat-model, append-only-event-chain, network-isolation]
---
# Vision review outbox
**The idea (user, 2026-09-06).** The Car Wash desk asks the operator for the vehicle's category,
and the entry camera now proposes one ([[venue-modules]] §Vehicle category from vision). The
operator's choice is what we would love to train the body-type classifier on — but the
operator **cannot be fully trusted** (mistake or intent; the [[threat-model]]). So the booth
hands each decision to a **trusted party** who reviews the picture and the label remotely,
and *that* verdict is the training label — and, per operator, the honest-mistake / fraud rate.
The booths sit on a private zero-trust overlay (**Netbird**), so the hand-off can go to a very
locked-down collector without exposing anything to the open internet.
## Rules (all enforced in `apps/server/src/modules/carwash/review-outbox.ts`)
1. **Offline-first, never on the intake path.** Creating a wash order *queues* a package (fire
and forget — a failure is a log line); a background loop drains the queue when the overlay
is up. The wash never waits on the network.
2. **One-way.** The booth POSTs; nothing ever comes back into the booth's decisions. The signed
ledger ([[append-only-event-chain]]) stays the only record of what happened at the wash.
Reviewer verdicts stay central and reach the owner as a report per site.
3. **Nothing that names the site leaves the booth.**
- Only the vehicle **crop** (the detector's box + 8 % margin, ≤ 640 px) — no walls, no camera
OSD (date / camera name burned into the frame), no bystanders.
- The **plate is blurred inside the crop** on the booth, from the plate detector's own box.
- The booth is a **pseudonymous id** set at deploy (`CARWASH_REVIEW_BOOTH_ID`); the operator
is a **keyed hash** (`sha256(boothId:username)[:16]`). The mapping back to places and
people is the reviewer's, held off the collector. The dataset export drops even those.
- Boxes are stored as **fractions of the frame** on the vision read, so the crop is cut from
the stored (downscaled) snapshot copy.
4. **The network is not the auth.** A per-booth bearer token on top of the overlay; the booth
can do nothing at the collector but this one POST. Payloads are small (a crop ≈ 50–80 kB).
5. **Data minimisation.** Queued only when there is a vehicle box (no box = no sample); the
image is dropped from the row once delivered; a voided order is abandoned unsent; anything
older than 14 days is abandoned ("expired") rather than resurfacing a fortnight in a burst.
## The package
`multipart/form-data`: `meta` (JSON) + `image` (JPEG). Meta = `{ v, booth, item, order, at,
operator (hash), operatorCategory {id,name}, service, vision {class, confidence, categoryId},
downgraded, image {width, height, plateBlurred} }`. Headers: `Authorization: Bearer <token>`,
`X-Booth-Id`.
## Draining
Every `CARWASH_REVIEW_INTERVAL_SEC` (60): due items oldest-first, 20 per pass. `2xx` → sent
(image cleared). `400/404/413/415/422` → abandoned (the collector refused the package itself).
Anything else (auth not yet fixed, 429, 5xx, timeout, no route) → retry with backoff
`1 min · 2^attempts`, capped at 6 h. `GET /api/carwash/review/status` (site:read) and a line in
Setup → Car wash show queued / delivered / abandoned + the last error.
## Config
`CARWASH_REVIEW_URL`, `CARWASH_REVIEW_TOKEN`, `CARWASH_REVIEW_BOOTH_ID` — all three or the outbox
is off and **nothing is queued** (an unbounded queue nobody drains is worse than none). Set per
booth in the Komodo stack env; compose forwards them.
## The collector — skeleton built 2026-09-06 (`apps/collector`)
A deliberately small Fastify + SQLite service **in this monorepo** (so it imports the payload
contract and the class vocabulary from `@parking/shared` — the two ends cannot drift), delivered
to the reviewer's host by **its own Komodo stack** (`wash-collector` in `komodo/resources.toml`
→ `docker-compose.collector.yml` only; the booth stacks never see it and it never sees booth
services). Image `parking-collector:<branch>-<sha>` from the same workflow as the others.
Three surfaces, nothing else — it must not grow into a fleet console:
- **`POST /ingest`** — bearer token **per booth** (`COLLECTOR_BOOTH_TOKENS`, `boothId:token`
pairs; constant-time compare), `X-Booth-Id` must match the token's booth, multipart `meta` +
`image` (JPEG magic checked, 2 MB cap), `meta` validated field by field against the contract
above (unknown vision class, non-id item, wrong booth → 422), **idempotent on the item id**
(a retry after a lost 2xx → 200 `duplicate`). Stored: `crops/<booth>/<item>.jpg` on the
volume + one `items` row. The booth now also sends `operatorCategory.classes` (the classes
the chosen category covers at that site) so a reviewer's CLASS can be judged against the
operator's CATEGORY without the site's setup.
- **`/review`** (+ `/api/items`, `/api/items/:id/image`, `/api/items/:id/review`, `/api/stats`)
— the reviewer's screen, served by the process itself (no build, no framework): one pending
crop at a time, the operator's pick and the camera's pick beside it, one button (and one
key) per vocabulary class + *unusable* + *skip*. HTTP Basic, one login
(`COLLECTOR_REVIEWER_USER/PASS`), over the overlay. Stats: per booth received / pending /
reviewed; per operator (booth + hash) **agree / disagree / unusable** — disagree = the
reviewer's class is outside the operator's chosen category. That column is the honest-mistake
/ fraud rate.
- **`GET /export/labels.csv`** — reviewed, usable rows: item, booth, crop path, the reviewer's
label, the operator's category + classes, the camera's class + confidence, downgraded, at.
Crops are not packaged: the phase-B trainer runs **on the same host** (its GPU) and reads them
off the volume — `docker-compose.collector.yml` carries the `trainer` seam as a commented
`profiles: [train]` one-off job (next increment).
**Deploy notes.** Bind the published port to the host's **Netbird address** (`COLLECTOR_BIND`),
never `0.0.0.0` on a host with a public interface; Netbird policy: booths → this host:8090 and
nothing else. The host must be onboarded as a Komodo server like the booths. `TAG` is pinned
and promoted with the booths (one sha for all stacks) — fine while the collector stays small;
its own repo the day it needs its own cadence.