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

6.5 KiB
Raw Blame History

title, type, status, related
title type status related
Vision review outbox — harvesting the operator's category choice for a trusted reviewer concept booth side built 2026-09-06; collector pending
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.