--- 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** (Car Wash not yet built; the module registry IS — see "As-built" below). Design captured from working sessions with the user on 2026-09-04/05. 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//` (`register(app, deps)` + schema), `apps/web/src/modules//` (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. ## As-built: the registry (2026-09-05, build-order steps 1 + 3) Built as the groundwork for the Car Wash pilot. `parking` and `validation` are registered; no parking code moved (the seam exists, the code crosses it as each subsystem is touched). - **`packages/shared/src/index.ts`** — `MODULE_IDS`, `ModuleManifest` {`id`, `required`, `dependsOn`, `resources`, `ledgerEventTypes`}, the `MODULES` registry, and the rules as pure functions: `parseEntitledModules(env)` (unset/blank = everything; required always in; unknown ids reported), `resolveModuleActivation(entitled, requested)` (required always in; refuses not-entitled and missing-dependency with a human-readable reason), `effectiveModules(entitled, activated)` (required ∪ entitled ∩ activated, dependency-broken modules dropped). - **DB** — `site_config.modules_json` (nullable JSON array; null = everything entitled), migration `0026_site_modules` (hand-written + journal entry: `drizzle-kit generate` needs a TTY and this repo's snapshots stop at 0003 — migrations have been hand-written since). - **Server** — `apps/server/src/modules.ts`: `entitledModules()` (env, read per request), `activatedModulesOf(row)`, `effectiveModulesFor(db)`, and the **`requireModule(db, id)`** preHandler (403, `code: "module_disabled"`), composed BEFORE `requirePermission` in a preHandler array so a disabled module answers identically for every role. `apps/server/src/modules/index.ts` iterates `MODULES` and calls each folder-based module's `register(app, deps)` (today: `modules/validation/index.ts` → `routes/validations.ts`, unchanged location, now guarded); boot logs `{entitled, effective}` so "why is X missing" is answerable from the container log. `routes/site.ts`: GET returns `modules` / `modulesEntitled` / `modulesActivated`; PUT accepts the full desired `modules` set, validates via the shared rules (400 with the reason), and signs one `config_change` `{setting: "modules.", value, prev, operator}` per module whose effective state actually flips (no-op resaves sign nothing). `routes/auth.ts` `sessionView` carries `modules` so the SPA can hide nav on first paint. - **Web** — `apps/web/src/lib/modules.ts` (`moduleOn(user, id)`, `WebModule` {nav, routes(root)}), `apps/web/src/modules/index.ts` (`WEB_MODULES`), `modules/validation/index.tsx` (the `/validate` route + nav entry, gated on module-on + permission). `router.tsx` spreads `WEB_MODULES` into the header nav and the route tree and no longer names the validate screen. `SiteSettings.tsx`: a **Modules** panel listing the entitled modules (required ones shown disabled, dependencies shown as a hint); each flip PUTs the full set and shows the server's refusal reason verbatim; the merchant-validation section only renders when `validation` is effective. i18n `modules.*` (en + sq). **Gotcha found in the browser check:** route-context consumers (the header nav) only re-read the router context on navigation, so `setUser(freshMe)` alone left the nav stale after a flip — `App.tsx` now `router.invalidate()`s whenever `user` changes (fixes the same latent issue for every other `setUser` caller). The programs fetch is also gated on the module being effective, so opening Setup → Site with validation off no longer logs a 403 to app_logs. Verified live (Playwright against the Vite dev server): flip off → "Validations" leaves the header and the validation sections hide; flip on → both return, no reload. - **Deploy** — `MODULES_ENTITLED=parking,validation` added explicitly to both booth stacks in `komodo/resources.toml`; documented in `apps/server/.env.example`. - **Lavazh station retired** (step 1): `STATIONS = ["bar"]`; existing `lavazh` program rows are untouched data (the server accepts any kebab slug) — they simply have no checkbox now. - **Tests** — `apps/server/src/modules.test.ts` (7): defaults; deactivate → 403 `module_disabled` + signed flip + reversible; required can't be deactivated; unknown id → 400; no-op resave signs nothing; `MODULES_ENTITLED=parking` → not offered, not activatable, routes 403; required entitled even when omitted, unknown ids ignored. Full suite 329/329. - **Acceptance test for Car Wash** (unchanged): one manifest entry, one `SERVER_MODULES` line, one `WEB_MODULES` line, its two folders, its migration — nothing else in the core touched. ## 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