The wash stream is small; the entry camera photographs every car in exactly the view the
classifier is trained on. The booth can now queue entry vehicle reads as pure training
material — crop + the camera's class, no order, no operator, no category.
- Core announces every vehicle read (deviceEvents.emitVehicleRead from snapshot.ts); the
Car Wash module listens, samples entry reads in-process (sampleEntry: exactly one in N)
and queues them (enqueueEntry). CARWASH_REVIEW_ENTRY_SAMPLE=N; 1 = every entry (storage
and bandwidth are not the limit — user); 0/unset = off. Forwarded by compose.
- Packages carry kind: "wash" | "entry". Collector: kind column, entry meta validated
without the operator fields, review screen shows an entry sample as such, export has a
kind column, operator agreement computed from wash items only. Setup line shows
"1 in N entries sampled"; status carries entrySample.
- komodo: park-2's four review lines enabled (collector URL by Netbird DNS name, booth-2,
the shared per-booth secret, every entry sampled) — the collector is up on the overlay.
- Tests on both sides. Wiki: vision-review-outbox (entry stream + the internet-feed
assessment), log.
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
The operator's category choice is a hypothesis, not truth (user, 2026-09-06): each wash
order with a vehicle read queues a package for a trusted reviewer over the private overlay
(Netbird); the verdict becomes the phase-B training label and the per-operator error rate.
wiki/concepts/vision-review-outbox.md.
- Boxes: the vision service returns the vehicle bbox; snapshot.ts stores the vehicle and
plate boxes on the read as FRACTIONS of the analysed frame (the stored snapshot is a
downscaled copy); vehicleForIdentity() returns them.
- carwash_review_outbox (migration 0031) + review-outbox.ts: crop = detector box + 8 %
margin, ≤ 640 px, plate blurred in place from the plate box; payload carries a
pseudonymous booth id and a keyed operator hash — no site name, no plate, no OSD, no
bystanders; multipart POST with a per-booth bearer; 2xx → sent (image dropped);
400/404/413/415/422 → abandoned; anything else → backoff 1 min·2^n capped 6 h; voided
orders and items older than 14 days abandoned unsent. Nothing queued while unconfigured.
- Enqueue is fire-and-forget off the intake path in createOrder; the loop runs every
CARWASH_REVIEW_INTERVAL_SEC (60) and stops on close.
- GET /api/carwash/review/status (site:read) + a "Remote review" line in Setup → Car wash.
- Env CARWASH_REVIEW_URL / _TOKEN / _BOOTH_ID (all three or off) documented in
.env.example and forwarded by compose.
- Tests: review-outbox.test.ts (crop + blur on a synthetic frame, config/pseudonyms,
queue/drain/backoff/abandon, through the app). Wiki: new concept page, index,
venue-modules As built, log. The collector is not built.
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
Fills /analyze vehicle.body_type + confidence (car / motorcycle / bus / truck from COCO,
mapped to the shared vocabulary) for the Car Wash desk's category suggestion
(venue-modules.md §Vehicle category from vision). Advisory: the operator decides, a
confident downgrade is flagged, nothing is gated on it.
- vision_service/vehicle.py: pure numpy/cv2 letterbox (pad 114, raw BGR), stride-grid
decode, class-agnostic NMS, one vehicle per frame (the box holding the plate's centre,
else the largest); YoloxVehicleDetector on onnxruntime CPU, 2 intra-op threads.
- recognizer.py: WithVehicle composes the stage over any plate recognizer (stub included);
a failing stage yields vehicle=null + a "vehicle: …" note in /health.detail — never
costs the plate read. model_version reads "<plate>+yolox:yolox_s.onnx@640".
- settings: VISION_VEHICLE_MODEL_PATH (unset = off), _INPUT_SIZE (640), _MIN_CONFIDENCE
(0.4, the detector's floor; the flag threshold is site config).
- Dockerfile bakes yolox_s.onnx (best-effort curl at build; no network → stage off) and
sets the path; compose forwards it (empty = off); .env.example documents it.
- Measured on four real dev entry frames (DS-2CD1047G3H, 2560×1440): car at 0.83–0.88 in
~240–330 ms; empty lane with a person → none.
- tests/test_vehicle.py: decode/NMS/pick/letterbox on synthetic tensors, the composition,
and a missing-model /health. Wiki: opencv-anpr-service, venue-modules, log.
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
The Komodo stack env alone is compose interpolation input; only variables in the
service's environment: block reach the container. Without it every booth on 55d6242
had Car Wash entitled (unset = every module).
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
The server's compose environment: block is an allowlist — it only forwards the vars
it names. BACKUP_KEY was never added when the backup feature landed, so even though
Komodo wrote BACKUP_KEY into the Stack .env, compose dropped it and the container
came up without it (docker inspect showed JWT/SIGN present, BACKUP_KEY absent — not
empty, absent). The Backup screen correctly reported 'BACKUP_KEY missing'.
Add BACKUP_KEY: ${BACKUP_KEY:-} next to EVENT_SIGNING_KEY (optional, empty default —
backups stay off until it's set). The prod overlay only merges VISION_URL, so the
base addition flows through to prod.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
In prod the containerized server sat on the Docker bridge (172.18.0.x) and could
only see eth0 — so the setup backend-IP picker (net.ts networkInterfaces) showed
just the Docker IP, the server couldn't reach the relay or fetch Hikvision ISAPI
snapshots, and push devices (readers/cameras) couldn't reach it. The server is the
ONLY container doing device I/O, so put it on the HOST network namespace.
- docker-compose.prod.yml: server + proxy → network_mode: host (server detaches the
base `parking` network via `networks: !reset []`). server VISION_URL=127.0.0.1:8089.
vision stays BRIDGED (it never touches a device — the server hands it JPEG bytes)
but publishes 8089 on 127.0.0.1 only, so the host-net server reaches it over
loopback while the ANPR service stays off the LAN.
- docker-compose.yml: VISION_URL is now ${VISION_URL:-http://vision:8089} so dev keeps
compose-DNS service-name routing; prod overrides to loopback.
- Caddyfile: reverse_proxy 127.0.0.1:3000 (was server:3000 — service DNS doesn't
resolve on host net). Dev doesn't use Caddy, so unaffected.
Merge validated for both envs (booth.sh config, exit 0). Host-net side effect: the
container ping_group_range sysctl is a no-op — the HOST must set it for reader ICMP
liveness (see appliance-provisioning).
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
The base compose only forwarded DATABASE_URL/VISION_URL/JWT_SECRET, so a booth deploy
was missing the vars that actually make it usable on the plain-HTTP LAN:
- COOKIE_SECURE (default 0) — without it auth cookies are HTTPS-only and operators
CANNOT log in over http. The #1 booth-deploy footgun.
- WS_ALLOWED_ORIGINS — the live-feed WS rejects the browser Origin without it.
- EVENT_SIGNING_KEY — dedicated ledger key (falls back to JWT_SECRET if empty).
- VISION_ENABLED=1 — the server's ANPR master switch.
All driven from .env; verified via `docker compose config` that the seven vars resolve.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Containerize the two non-desktop apps for the booth appliance. The desktop app stays
on its own tag-only release.yml.
- apps/server/Dockerfile: multi-stage node:22-alpine. `pnpm deploy --legacy --prod`
(NOT prune — the monorepo native better-sqlite3 won't resolve under a root prune)
yields a self-contained bundle; build stage adds node-gyp toolchain, runtime adds
libstdc++; non-root, healthcheck. Migrates the mounted DB on boot via a drizzle-kit-
free runtime migrator (packages/db/scripts/migrate-runtime.mjs) — drizzle-kit is a
devDep, pruned from prod.
- apps/server/src/static-spa.ts: Fastify serves the built React SPA (one container
serves API + UI). GET-only fallback to index.html, excludes /api + /health so it never
shadows the backend; a no-op in dev (no dist). Registered last in server.ts.
- apps/vision/Dockerfile: uv base, --extra alpr, model weights PRE-WARMED into the image
as the runtime user so fast_alpr boots offline (0 downloads at runtime). Engine env-
selected (VISION_RECOGNIZER stub|fast_alpr).
- Branch-aware: docker-compose.yml (base) + .dev.yml (build local, stub, ports) +
.prod.yml (pull pinned, fast_alpr, vision internal, restart always); REGISTRY/TAG from
env so a branch deploy pulls that branch's image.
- .gitea/workflows/build-images.yml: on push to dev/main, run the full turbo build+lint+
test gate, then buildx push both images to git.infra.msai.al/mca/parking_solution with
branch + branch-<sha> tags (registry cache; optional Komodo webhook behind KOMODO_ENABLED).
- .dockerignore excludes **/parking.sqlite* so the signed ledger is NEVER baked.
Verified locally (Docker 29): server image migrates + serves API+SPA (/health 200, /
+ /booth HTML, /api/nope JSON 404, no sqlite outside /data); vision image boots fast_alpr
with 0 runtime downloads; compose stack healthy with server→vision over the private network.
Wiki: new container-deployment.md; vision-service-packaging open Qs resolved; index + log.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V