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 app plumbing for venue-modules.md §"Vehicle category from vision"; the model is the
open half (no bundled recognizer emits body_type yet, so the desk shows nothing until
phase A lands in the vision service).
- Shared: VEHICLE_CLASSES vocabulary, VehicleRead, CARWASH_VISION_THRESHOLD_DEFAULT,
reason code carwash.categoryDowngrade; settings/order/lookup views carry the read.
- Vision contract: /analyze vehicle.body_type + confidence (service schema); the Node
client normalises to the vocabulary and drops the rest.
- Record: snapshot.ts stores the read in the plate's device_events row (or its own when
the plate was unreadable); vehicleForIdentity() resolves it like the plate.
- Car wash: carwash_categories.vision_classes (site mapping "car, sedan → Vetura"),
carwash_config.vision_threshold (signed config_change when it moves), four vision
columns on orders — migration 0030. Lookup returns vision + suggestedCategoryId.
- Desk pre-selects the mapped category and shows the read + snapshot thumbnail; Setup
offers class chips per category and the threshold. Operator decides.
- Flag: a read at/above the threshold whose mapped category prices HIGHER than the chosen
one signs one `anomaly` (both categories/prices, operator, snapshot) and stores its id on
the order. Equal/upgrade/unsure/unmapped → nothing. Recorded only, never blocks, no
reason prompt (user, 2026-09-06).
Tests in carwash.test.ts; wiki venue-modules (As built), opencv-anpr-service, log.
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
Closes the permissions-matrix loose ends (venue-modules.md §Permissions matrix):
- `role_jobs` (migration 0029): a role stores the manifest jobs it was composed from
(chips on at save + any bundle fully present). `jobById` / `jobsBehind` in
@parking/shared surface a followed job whose bundle grew past the role in a later
release; the roles list shows a "behind <job>" badge with a one-click "Update to job"
(the union, nothing removed) and the editor lints it. Never a runtime union: the grid
stays the explicit enforcement layer and an update never widens a role without a click.
- Every role create/update/delete appends a `config_change` (`role.<id>`, prev/value =
name + sorted permissions + jobs, operator); a no-op resave signs nothing. roleRoutes
now takes the ledger.
- booth-supervisor already carries subscription:*; the stale open note is closed.
Tests: routes/roles.test.ts. Wiki: venue-modules status, local-jwt-auth, log.
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
Closes the three known follow-ups of the Tills decision (venue-modules.md):
- Activity log per till: `tillOfEvent(type, payload)` in @parking/shared (money events
by payload till, other events by their owning module's till, everything else booth),
applied by `/api/events?till=` in SQL and passed by the hub log, the Drawer "today"
panel and the booth feed (history + live pushes). The events route admits a role that
holds a module feed permission without event:read and returns only that module's
event types — the live-socket rule.
- Booth Z-report: `chargesByModuleMinor` sums the chargeLines on the till's payments by
module; the ticket bucket excludes them (Bileta = parking only); printed
"Lavazh (në biletë)" only when any was taken. The wash till's slip prints "Lavazh:".
- Printer role `wash-desk`: the wash till's Z-report and vouchers print there, falling
back to the booth printer; nothing falls back to the desk. `printerRoleOf()` is the
one reading of the role field (the entry/booth loaders treated any non-booth role as
an entry dispenser). Footer label "at wash desk".
Also: `GET /api/carwash/settings` opens to carwash:read OR site:read (new
requireAnyPermission) — the Wash operator job could not load the desk's category and
service pickers. Tests for all four; wiki (shift, printer-roles-failover, venue-modules,
log) updated.
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
A site entitled to parking,carwash had the wash silently dropped as dependency-broken.
The validation program routes (compose/read) leave the validation module gate; the
merchant scan routes (mine/lookup/apply/void) stay behind it.
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
Permissions matrix rethink (wiki/decisions/venue-modules.md §"Permissions matrix",
open-questions #16) — the grid stays the enforcement layer:
- Move 1: each desk's money is guarded by that desk's own permissions. Manifest
tillGuards {read, shift, cash}: booth = shift:read / shift:create / drawer:create
(unchanged), carwash = carwash:read / carwash:cash (new). Shift + drawer routes
resolve the guard FROM THE TILL (requireTill); a wash role holds no shift:* and cannot
touch the booth by construction. Replaces the session:read borrowing (tillPermission).
/api/shift/tills lists the role's readable tills with canWork; history/movements
without a till filter return the union of readable tills.
- Move 2: jobs — manifest permission bundles (booth-operator, booth-supervisor,
merchant, wash-operator) as one-click chips in Setup → Roles, with "mixes desks" and
"partial job" lints (warnings, never blocks).
- Move 3: the live WebSocket admits any watch permission (event/session/device read or
a module's feedPermission) and filters every push per role; report:read is the
reports screen only.
Auth: the token's roleId is only a hint — refreshRole() after every jwtVerify resolves
the user's CURRENT role (cached, bumped on role/user writes), so reassigning a user's
role applies on the next request and a deleted user's session ends with 401.
Tests: till guards + look-only role, feed rules, every job's permissions exist, role
reassignment without re-login. 353/353.
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
Car Wash — the pilot venue module (wiki/decisions/venue-modules.md):
- Master data (categories × services price matrix) at /setup/carwash; the desk at /wash
(ticket lookup → order; open queue oldest-first: Done / Paid cash / Paid card / Void;
Finished list). Orders freeze names + price; their life is signed (carwash_order,
carwash_payment). Migration 0027.
- Where money is taken is a SITE setting (carwash_config.pay_at, migration 0028, signed
config_change on a flip) — no per-order radio; a stale client is refused (409).
- Core seams: PayStation charge providers (a booth-paid wash rides the parking payment as
chargeLines) + applyValidation() shared with the merchant route. A bay-paid, done wash
signs the $0 parking payment so the exit reader releases the car.
- "Parking discount" modes for the wash: free while the wash runs (+ tolerance) and wash
price off the fee (floored at 0), resolved at done and anchored at the order's intake
(the entry-anchored version comped a 74-day stay); typed-amount and percent hidden for
the wash. Long durations render y/d/h/m.
Tills — a shift belongs to a till, not the site (wiki/concepts/shift.md §Tills):
- TillId booth|carwash; every money event names its till (absent = booth, so the chain
re-folds identically). ShiftService is per till: single-open, folds, X/Z-reports,
vouchers, carry-forward. A bay payment needs the carwash shift.
- Working a till needs that till's module permission (manifest tillPermission; 403
till_forbidden); /api/shift/tills lists only the role's tills.
- Web: ShiftButton per till (header = booth, wash desk = carwash); shift hub lists every
open shift with till badges + filter; drawer hub switches tills.
Modules: landing per module (index route resolves booth → module landing → shifts →
profile); guards bounce to "/", /booth needs session:read.
Tests: carwash e2e suite (settings, intake, booth/bay paths, modes, void, gate, pay-at
policy, till permissions), 6 per-till shift tests; suite green (1 pre-existing flaky
backup test under the parallel run).
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
Groundwork for the Car Wash pilot (wiki/decisions/venue-modules.md, build-order
steps 1 + 3). No Car Wash code yet; validation is the first module behind the
seam, unchanged in behaviour.
- @parking/shared: MODULE_IDS, ModuleManifest, MODULES (parking required;
validation dependsOn parking), parseEntitledModules / resolveModuleActivation
/ effectiveModules as pure functions.
- DB: site_config.modules_json (migration 0026, hand-written + journal;
additive, nullable = everything entitled).
- Server: modules.ts (entitledModules from MODULES_ENTITLED env, activated
from site_config, effective set, requireModule preHandler → 403
module_disabled); modules/index.ts registers folder-based modules by
iterating the registry (modules/validation); site-config GET exposes
modules/modulesEntitled/modulesActivated, PUT takes the full desired set,
enforces entitlement + dependency rules (400 with reason) and signs one
config_change per module that actually flips; /api/auth/me carries the
effective set; validation routes guarded requireModule → requirePermission.
- Web: lib/modules.ts + modules/{index,validation}; router.tsx spreads
WEB_MODULES into nav + route tree (validate route no longer named there);
Setup → Site "Modules" panel (required shown disabled, dependencies as
hints, server refusal shown verbatim); validation sections + programs fetch
gated on the module; App invalidates the router whenever the session
changes (route-context consumers only re-read on navigation — the nav was
stale after a flip, and after every other setUser too).
- Lavazh validation station retired (STATIONS = ["bar"]; rows untouched).
- Deploy: MODULES_ENTITLED=parking,validation explicit in both booth stacks;
documented in .env.example.
- Tests: modules.test.ts (7); suite 329/329; web build clean; Playwright
round-trip on /setup/site verified live.
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
Records the 2026-09-04/05 design sessions on wiki/decisions/venue-modules.md:
manifest-registry module system (folder per module, always-migrated schema,
one ledger union with prefixed event types, relations only via manifest
dependsOn + ledger events), enablement as entitled ∩ activated (vendor-set
Komodo env, site-admin site-config toggle recorded as config_change; server
enforces with requireModule, web only hides; disabling never deletes),
Parking recast as one module on a venue POS/audit core, Car Wash as the
pilot (inside the parking, entry snapshot as identity, bay camera for the
unrecorded-wash anti-fraud signals, v1 scope + build order), and vision
vehicle category as an advisory anomaly flag. Name stays parking-system;
validation stays for the Bar, only the Lavazh station retires with Car Wash.
Open-questions #15, index, log.
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU