docs(wiki): venue modules design — Car Wash pilot, Parking as a peer module (open decision)

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
This commit is contained in:
2026-09-05 10:24:20 +02:00
parent d86bffa500
commit db9c3e0e31
4 changed files with 282 additions and 1 deletions
+7 -1
View File
@@ -2,7 +2,7 @@
type: decision
tags: [parking, decisions, open]
sources: [parking-system-architecture]
updated: 2026-06-29
updated: 2026-09-04
status: open
---
@@ -129,3 +129,9 @@ procurement. (See [[parking-system-architecture]] §10.)
app code**, and are **unverified on hardware**. Close this once the printer transport per site is
fixed and (if USB) the udev/usblp rule is in the image and a real USB print is verified. Relates
to #1 (lane topology / image standardization). See [[printer-usb-transport]], [[rongta-printer]].
15. **Venue modules — Car Wash / Bar as peers of Parking.** _(Raised by the user, 2026-09-04.)_
Optional per-site modules on a shared venue core, with Parking itself becoming a module.
Name stays `parking-system` (settled 2026-09-05); validation stays for the Bar, only the
Lavazh station retires when Car Wash (the pilot module) ships. Full design and the remaining
questions on [[venue-modules]].
+237
View File
@@ -0,0 +1,237 @@
---
type: decision
tags: [parking, decisions, open, modules, architecture]
sources: []
updated: 2026-09-05
status: open
---
# Venue modules — Car Wash, Bar/Restaurant, and Parking as peers
**Status: OPEN.** Design captured from a working session with the user on 2026-09-04, after the
desktop-shell run closed. Nothing here is built. Decisions marked **(settled)** were stated by the
user in that session; everything else is the proposed shape awaiting a go.
## The ask
Some sites need a **Car Wash** (al. *lavazh*), some a **Bar / Caffè / Restaurant**, some both, some
neither. These must be optional per site, may relate to each other, and must not be second-tier
citizens of a product whose every identifier says "parking".
## What exists today (and the gap)
Optionality already appears three ways, none named: a process-level env flag (`VISION_ENABLED`
→ [[vision-service]]), a per-site DB flag (`validation_programs.active`, the Bar/Lavazh
checkboxes of [[validation-discounts]]), and permissions gating nav/routes ([[local-jwt-auth]]).
There is **no registry**: adding a feature hand-wires seven places (`server.ts` route list, the
`RESOURCES`/`PERMISSIONS` catalog in `@parking/shared`, the `LedgerEventType` union, a schema
table + migration, `router.tsx` routes + nav, i18n). Nothing keeps them consistent and nothing
lets the server say "this feature is off at this site."
## Decisions taken in the session
1. **Validation stays — for the Bar (revised 2026-09-05).** The 2026-09-04 session first
settled on decommissioning [[validation-discounts]] entirely; the user revised this the next
day: the merchant-scan validation is needed **as is for the Bar** until a Bar management
module exists, at which point it folds into that module. Only the **Lavazh station is
retired** when Car Wash ships (Car Wash sponsors parking through its own order flow, below),
so `STATIONS` shrinks to `["bar"]`; no tables are dropped, the `"validation"` ledger type stays
live. Consequence, accepted: two sponsorship mechanisms coexist for a while — merchant-scan
validation (Bar) and order-driven comp/credit (Car Wash). In the registry, validation is
registered as its own module (`validation`, no dependencies) so the Bar module can later
declare `dependsOn` or absorb it.
2. **Parking is a module, a peer of Car Wash and Bar (settled in principle).** What is *not*
parking in the server today — identity/roles, the signed ledger, device adapters + monitoring,
[[shift]], cash drawer, payment terminal, receipt printing, reports, site config, logs, backup —
is a point-of-sale + audit **platform for a venue**, and every new module needs all of it. The
core is that list; Parking is access control (barriers, readers, ANPR), [[parking-session]],
occupancy, [[tariff]], [[subscription]].
3. **Updates/enablement authority — two layers (proposed, user agreed in discussion).** See below.
## Proposed shape
### One binary, modules enabled per site at runtime
Not build variants, not a package per site. The fleet just reached "same image + same installer at
every booth" ([[fleet-deployment-komodo]], [[desktop-shell-tauri]]); per-site builds would undo
it. Enabling is a runtime decision, recorded, reversible.
### A module = a manifest + three folders
- **Manifest** in `packages/shared`: `id`, `dependsOn: id[]`, the permission `resources` it
contributes, the `ledgerEventTypes` it appends (prefixed: `carwash_*`, `bar_*`), site-config
defaults.
- **Folders**: `apps/server/src/modules/<id>/` (`register(app, deps)` + schema),
`apps/web/src/modules/<id>/` (routes + nav entries), an i18n namespace.
- **Registry**: one array of manifests. `server.ts` iterates it instead of ~30 flat calls;
`router.tsx` likewise. Adding a module = a folder + one registry line; a missing piece fails at
startup, not in the field.
- **Not packages yet.** Folder-per-module is enough at this scale; packages earn their keep only
when a module needs its own release cadence, and with one image per commit none will.
### Enablement: entitled ∩ activated
- **Layer 1 — entitlement (vendor).** What a site *may* have is a commercial/deployment decision
and belongs to the vendor, not to any app role. Home: the Komodo stack environment, next to
`VISION_ENABLED`/`TAG`/secrets — e.g. `MODULES_ENTITLED=parking,carwash`. Changes only via a
Komodo sync + redeploy (the vendor's channel, offline-safe, invisible to app roles). This is
exactly today's vision pattern: env = entitlement, `anprEntryEnabled` = the site's own switch.
- **Layer 2 — activation (site admin).** Whether the site is *using* it now (a car wash closed for
winter) belongs to the site admin, within the entitled set: a checkbox in Setup → Site, stored
in `site_config.modules`, every change a signed `config_change` ledger event with the actor
(the presence-bypass precedent, [[entry-presence-bypass]]). Rides on `site:update`; a separate
`module:update` permission is one line later if a "tariffs but not modules" owner role is ever
needed. No new super-role.
- **Effective set = entitled ∩ activated.** The server enforces it with a `requireModule(id)`
guard beside `requirePermission` (permissions alone are insufficient: a role may hold
`carwash:create` at a site with no car wash). `/api/site-config` and `/api/auth/me` expose it
so the SPA can hide nav before it knows anything else — the web only *hides*, the server
*enforces*.
- **Rules:** disabling never deletes (tables, history, role grants all stay; routes reject, UI
disappears; re-enable restores). Dependencies enforced at the point of change from the
manifests: enabling Car Wash with a dependency off enables it or refuses with a message;
disabling a dependency of an enabled module refuses. Entitlement is a boundary against app
roles, not against root on the box — right level given [[disk-os-hardening]]; if commercial
enforcement ever matters, it becomes a small **minisign-signed** file checked with the same key
infrastructure the updater already uses, with nothing above changing.
### Schema and ledger stay uniform
Tables for every module are **always migrated**, enabled or not (empty tables; no conditional
migrations on an offline appliance). The ledger stays **one** append-only union; module event
types just carry the module prefix, so the chain and its signing never change shape.
### Relations between modules: manifest + ledger, never imports
Car Wash and Bar both need "this customer's parking is sponsored/discounted". For the Bar that
is today's merchant-scan validation, kept as is (decision 1). For Car Wash it is expressed as
**events**: a `carwash_wash_complete` event is appended; the parking module reacts (comp/credit
the session) through the existing event bus ([[event-streams-split]]). A module never calls another module's
routes or imports its code; `dependsOn` in the manifest is the only coupling the registry knows.
Merchant-type users (bar tender, wash operator) belong to the module they operate.
### Naming and identity — one irreversible constraint
The product name touches three things at different costs:
- **Desktop app `identifier` (`com.parking.desktop`) and the `.deb` package name (from
`productName`) — irreversible in practice.** Changing either means an installed booth will not
update into the new app: dpkg treats it as a different package, and a new identifier gets a
fresh config dir (saved server address + session lost). Survivable now with one staging booth
and a manual reinstall; a fleet migration later. **Settle the platform name and apply it here
before the second booth is provisioned, then never touch it again.**
- Repo name / Gitea project / image names / Komodo stack names — cheaper, but each is a place
the wiki and runbooks point at. Let them follow at the point the first non-parking module ships.
- Package scope `@parking/*` — can stay until the core/parking boundary exists in code. Renaming is
cheap once, expensive twice.
### Migration path (no big-bang)
1. Remove validation (its own commit).
2. Decide the platform name; apply it to the desktop identifier + package name (irreversible one
first).
3. Introduce the registry: `site_config.modules`, manifest type, `requireModule`, nav gating, the
entitlement env. Register **parking** as the first module *without* moving code yet
(its seam is drawn in a wiki page; code moves across it as each subsystem is touched, starting
with the obviously-core pieces such as shift and cash).
4. Build **Car Wash** as the first new module against the contract: it must need zero changes to
core files beyond its own folder and registry line. Its data model, queue, services, pricing
are a separate scoping conversation.
## Vehicle category from vision — advisory, flagged, never authoritative
Raised alongside: Car Wash prices by body type (e.g. **SUV > Car**), so can the ANPR service
help? Facts first ([[vision-service]], [[opencv-anpr-service]]): the service is **stateless**
(`GET /health`, `POST /analyze`, no DB, no volume; the Node server is the only writer of record),
and today's `fast-alpr` is plate-only — YOLOv9 *plate* detector + CCT OCR, no notion of the
vehicle. The Hikvision push's `detectionTarget` only says `vehicle`/`human` on the G3H.
- **Cheap path:** a general detector beside the plate detector, behind the existing `Recognizer`
class boundary (no app change). Licence decides the model: Ultralytics YOLOv8 is **AGPL — out**;
**YOLOX** and **RT-DETR** ship Apache-2.0 ONNX weights on the ONNX Runtime already in use. COCO
gives `car/bus/truck/motorcycle/bicycle` — **no van, minivan, pickup, and no SUV vs sedan**.
- **Real path for SUV-vs-Car:** a body-type classifier (sedan/hatchback/SUV/minivan/pickup/van)
fine-tuned on a few thousand **own entry-camera frames** on an Apache-2.0 backbone; public
car datasets are often research-only — check the licence before touching one. Expect 85–95 %
on frontal gate views once tuned: enough to *flag*, nowhere near enough to *bill*.
- **Design (the [[threat-model]] shape — operator is the adversary):** vision **proposes**, the
operator can override, the override is on the record.
- Wash intake ties the order to the parking session by plate, so the **entry snapshot already
exists** — no new camera, no new capture; classify the vehicle crop in that frame.
- The intake form pre-selects vision's category. The order stores `visionCategory`,
`visionConfidence`, `operatorCategory`, actor.
- Differ **and** confidence ≥ threshold → append an **`anomaly`** ledger event (existing type,
new reason) with the snapshot attached; a reviewer sees car, both categories, operator, in one
row. **Downgrades** (vision SUV, operator Car — the cash-difference vector) get the flag and
optionally a mandatory reason; upgrades log without one. Reports: discrepancies per operator
per [[shift]].
- **Never block.** A wrong classifier must not stop a wash. Flag, don't gate.
- Classifier output is advisory data on the event, **never a tariff input by itself**; threshold
and the flagged-category set are **site config** (a minivan-heavy site tunes the noise down).
- CPU: a second model per frame on the i5-8500 — analyse one frame per vehicle, not every push.
## Car Wash — the pilot module (settled 2026-09-05)
- **Car Wash is the pilot for the registry (settled).** It is built *as* the first module, and
the acceptance test of the module design is that it needs zero changes outside its own folder
and registry line. Validation removal clears the ground first.
- **The wash sits inside the parking (settled).** Every vehicle therefore already has a
[[parking-session]], a plate, and an entry snapshot — the plate is the customer identity for
free, no intake capture, and every anti-fraud signal below works from day one. Walk-ins from the
street are out of scope.
- **A camera on the wash bay (settled).** Through the same stateless [[vision-service]]; its job
is presence/vehicle counting at the bay, not plates.
### v1 scope
- **In:** a services catalogue priced by vehicle category; orders with a queue (waiting → in
progress → done → paid); vision's category as the advisory pre-selection with the override
flag (above); payment through the existing [[shift]] / cash drawer / P2PE terminal; receipts
on the existing printer path; one integration with parking — a completed wash may comp or
credit the session, emitted as a ledger event the parking module reacts to; reports per
operator and per shift. A wash desk is just a second desktop install pointed at the same
server (runtime backend address, [[desktop-shell-tauri]]).
- **Out (each is its own module-sized thing):** memberships / prepaid packages, loyalty, chemical
stock, staff scheduling, appointment booking, customer accounts. Design the order so a payment
can later reference a package, and stop there.
### Anti-fraud — the reason this fits here and not a generic wash product
Same adversary as the booth ([[threat-model]]): the person taking cash. The fraud is the
**unrecorded wash** — cash pocketed, nothing in the system. Two signals, both from things the
platform already owns:
1. **Session vs order.** A vehicle that dwelt at the bay (bay camera presence, or simply a long
session with no order) and exited with no wash order → `anomaly` ledger event with the entry
snapshot and the dwell evidence attached.
2. **Bay count vs order count.** The bay camera counts vehicles washed per shift; orders recorded
per shift come from the ledger; a divergence above a site-config tolerance → `anomaly` per
shift, on the operator's record. Never blocks the wash; reporting only.
Plus the category-override flag described above (SUV recorded as Car).
### Build order
1. Retire the Lavazh validation station (small; Bar station and all tables stay).
2. ~~Platform name~~ — settled, unchanged.
3. Registry: `site_config.modules`, manifest type, `requireModule`, nav gating,
`MODULES_ENTITLED` env; register `parking` and `validation` without moving code.
4. Car Wash v1 as above, on the staging booth. Vision category last — it needs gate frames
collected and labelled first; the bay-count signal can ship before it (presence only).
Rough size: four to six weeks including the registry.
## Open questions to settle before building
- ~~Platform name~~ — **settled 2026-09-05: it stays `parking-system` / `com.parking.desktop`.**
"This is a Parking Systems after all." The second-tier concern is answered by the architecture
(peer modules on a shared core), not by renaming; the irreversible-identifier warning above
remains true and is now simply moot.
- **Which body-type categories the Car Wash tariff actually needs** — decides COCO-five vs
training.
- **Entitlement as env vs signed file** — start with env; revisit only for commercial reasons.
- The **Bar** data model — separate scoping session (Car Wash v1 scope is above).
## Related
[[standing-decisions]] · [[desktop-shell-tauri]] · [[fleet-deployment-komodo]] ·
[[validation-discounts]] (kept for the Bar; Lavazh station retired) · [[validation-sponsorship]] · [[vision-service]]
· [[threat-model]] · [[append-only-event-chain]] · [[open-questions]] #15