Files
parking_solution/wiki/decisions/venue-modules.md
T
julian e14e31a840 feat(tills): per-till activity log, wash bucket on the booth Z-report, wash-desk printer role
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
2026-09-06 12:37:26 +02:00

569 lines
41 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 v1 and the module registry are BUILT — see the two "As-built" sections; open items remain 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/<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.
### v1 answers from the user (2026-09-05) — these shape the tables
1. **Price = category × service.** The admin declares a price per (vehicle category, service)
pair — e.g. Car·Standard 500, Car·Inside 300, Car·Outside 300, SUV·Standard 700. Categories
(Car, SUV, Van, Truck, …) and services (Standard, Outside, Inside, Details, …) are both
admin-maintained lists; a missing pair simply isn't sellable.
2. **Where the money is taken — "in booth" or "in bay" — is a SITE setting** (Setup → Car
wash; **changed 2026-09-05 from a per-order radio** at the user's request: "remove it from
/wash"). The desk shows the policy in force read-only; every order freezes it
(`carwash_config.pay_at`, migration 0028; a flip signs `config_change carwash.payAt` with
prev/value — it decides which till the cash lands on and which device releases the car, so it
is attributed like other fraud-relevant config). A stale client sending the other value is
refused (`409 pay_at_policy`), never silently overridden.
- *In booth*: the wash is a line on the parking settlement at the booth; after that payment
the exit-lane barrier opens exactly as it does for a parking-only exit.
- *In bay*: the wash operator collects at the bay; the customer then leaves by scanning the
ticket barcode at the exit **reader**, which must open — i.e. the parking session must be
settled to zero-due by then (see 4).
3. **Queue = a plain list of open orders, oldest first.** No display board, no "next car".
4. **The wash grants the parking discount through the same ability validations have**
(review 2026-09-05, after the first build — labelled **"Zbritje parkimi"** / "Parking
discount", not "sponsorship"). The wash editor offers: *free* (comp), **free while the wash
runs + N minutes tolerance** (`doneTolerance`, resolved at done into a timeCredit of the
WASH WINDOW — order intake → done — plus N; NOT the time since entry: a first build
anchored it at entry and a ticket parked 74 days would have been comped by a wash —
caught by the user on ticket 92498375903 the same day; parking before the order and
after the tolerance stays at the tariff), **the wash price off the parking fee, floored at 0** (`washPrice`, resolved
into a fixed discount of the order's price), and first-N-minutes. It does NOT offer the
typed-amount mode (the only mode where the operator picks the money — the highest-risk one,
kept for the Bar behind its cap + per-day limit + attribution) nor percent (a real Bar use,
not a wash one). The two wash-only modes can't be applied by a merchant scan (400) — they
need a wash order's context, and the signed event records the resolved mode plus
`programMode` for audit. The
site admin configures, for the car wash, the same program shape a merchant validation has
(comp / first N minutes free / amount / percent, max per day); a completed wash applies it
to the customer's session automatically, attributed to the wash operator. ~~So the module
`dependsOn` **validation** (the sponsorship engine) as well as parking~~ — **corrected
2026-09-06:** the discount ENGINE (program rows + `applyValidation()`) is CORE; the
`validation` module is only the merchant's scan screen. Car Wash depends on parking alone
(a site set to `MODULES_ENTITLED=parking,carwash` had the wash silently dropped as
"dependency broken" — the user's first field test). The earlier "own event, validation
absorbed later" idea stays superseded: the engine IS the shared piece. With
program = comp, an in-bay-paid wash lets the car out at the reader; with a partial program
the remainder is still paid at the booth (the reader refuses, as for any unpaid session).
### 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.<id>", 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.
## As-built: Car Wash v1 (2026-09-05) — the pilot, delivered
Built the same day the v1 answers landed. Everything the module is lives in
`apps/server/src/modules/carwash/` and `apps/web/src/modules/carwash/`; the core changed only
at the two seams the design names, and the registry earned its keep: **one manifest entry, one
`SERVER_MODULES` line, one `WEB_MODULES` line, one migration, two folders.**
- **Data** (`0027_carwash`): `carwash_categories`, `carwash_services` (admin lists, soft-delete),
`carwash_prices` (category × service → minor units; a missing pair is unsellable),
`carwash_orders` (the queue; names + price + currency FROZEN at intake; `pay_at` booth|bay;
`status` open|done|void; paid-ness is `paid_at` + the settling event id, separate from status
because a bay order may be paid before or after the wash).
- **Ledger:** `carwash_order` (payload `action` created|done|void, frozen names/price) and
`carwash_payment` (money at the bay). A wash paid at the booth is NOT its own event — it rides
the parking `payment` as `chargeLines` / `chargesMinor` / `parkingMinor`.
- **Two core seams, both deliberate:**
1. **`PayStation.registerChargeProvider()`** — a module folds charges into the booth
settlement: `lines(identity)` at quote time, `onPaid(identity, lines, payment)` after the
payment is signed. `Quote`/`SessionLookup` gained `chargeLines`, `chargesMinor`,
`parkingMinor`; `BoothPayModal` renders the "+" lines. A provider fault is logged and
priced around, never blocks a parking settlement.
2. **`applyValidation()`** extracted from the merchant route into `validations.ts` — the
decision chain + signed append, shared; the merchant route keeps only its program↔user
binding check. The wash applies the site's **`carwash`** validation program (composed on
Setup → Car wash with the same `StationForm`, users hidden) when an order is marked done,
attributed to the wash operator.
Plus the shift money folds (`#drawerBalanceAt`, Z-report tender totals) now include
`carwash_payment` so the expected drawer is right; a separate wash bucket on the Z-report is
a follow-up.
- **The exit-reader rule, honoured:** a validation alone opens nothing — the reader checks for
a signed `payment` + grace. So after a bay payment on a done order the module asks the core
for a quote and, if the sponsorship made it zero-due, signs the $0 parking payment via
`PayStation.pay()`. A partial sponsorship leaves the remainder for the booth (verified).
- **Modules reach the core only via `ServerModuleDeps`** (db, eventLog, payStation,
shiftService) — no module imports another; `dependsOn: ["parking"]` (validation dropped
2026-09-06; the program routes moved out from behind the validation gate — the merchant
scan routes stay gated).
- **Deploy gotcha (2026-09-06):** `MODULES_ENTITLED` reaches the container ONLY through
`docker-compose.yml`'s `environment:` block — a value in the Komodo stack env alone is just
compose interpolation input. It was missing there, so every booth on `55d6242` had Car Wash
on (unset = everything). Fixed: compose forwards it with a default of `parking,validation`;
a booth is never entitled to a module its stack env does not name. Check on the box:
`docker exec <stack>-server-1 env | grep MODULES_ENTITLED` and the boot log line
`venue modules (entitled = …; effective = …)`.
- **Web:** `/wash` (the desk: ticket lookup → category/service/price → order, the site's
booth|bay policy shown read-only;
the queue oldest-first with Done / Paid cash / Paid card / Void) and `/setup/carwash`
(categories, services, the price matrix, the sponsorship program). `WebModule` gained
`setupNav` / `setupRoutes`; the Setup tab bar spreads them like the header does. i18n en+sq.
- **Verified:** 7 new server tests (`modules/carwash/carwash.test.ts`: settings + signed
config_change; intake rules + oldest-first queue; booth path = charge line on quote + payment
payload + order marked paid; bay path = validation applied, `carwash_payment`, $0 parking
payment, within grace, queue empty; partial sponsorship leaves a balance; void takes back a
live sponsorship; module off = 403 + no charge lines). Suite 337/337 (two backup-service
tests flake under the parallel run, pass in isolation — pre-existing, unrelated). Live in the
browser on the dev server: activated the module in Setup → Site (header + Setup tab appeared
without reload), saved the sponsorship, seeded master data, and ran a real bay-paid SUV wash
against an open ticket — ledger read `carwash_order:created → validation → carwash_order:done
→ carwash_payment 70000 → payment 0`, session `paidAt` set, `withinGrace: true`.
- **Review fixes (same day):** the price matrix let you type prices for new rows only after a
save (new rows had no id) — the Save button now does two requests behind one click (lists
first, then prices mapped to the returned ids). Discount modes extended as in "v1 answers" 4.
- **Review fix 2 (same day):** `doneTolerance` re-anchored at the order's intake (above);
long durations now display as `Xy Xd Xh Xm` everywhere (`formatDuration` /
`formatMinutes`), so a stale ticket reads "74d 21h 23m", not "1797h 23m".
- **Not yet:** the booths' `MODULES_ENTITLED` stays `parking,validation` — entitle `carwash`
per site when a site buys it. Vision category (advisory flag) and the bay-camera signals are
the next increment, as designed. Receipt label for a booth-paid wash is `Lavazh — <category> ·
<service>` (Albanian, frozen on the payment).
## Permissions matrix — rethink (OPEN DECISION, raised 2026-09-05; moves 1–2 built same day)
**Why (user: "I feel we opened Pandora's box with this car wash module. We need to rethink
the permissions matrix.").** The flat `resource:action` grid was composed for ONE desk. Three
things broke once a second desk existed:
1. **Permissions named data, not jobs, and their meanings got borrowed.** `session:read` meant
"may use the booth screen"; on the first tills cut it also decided who may work the booth
till. `report:read` meant "may open the live socket". `shift:create` opened *the* shift. Each
was a proxy for a job, and proxies are how the dev `Lavazhier` role ended up with booth
rights and without `carwash:create`.
2. **Cross-cutting resources have no owner.** Shifts, drawer, events, the feed are core, but
every *instance* now belongs to a desk; the grid cannot say "shifts, but only the wash's".
3. **The composer is at the wrong altitude.** ~60 checkboxes of nouns and verbs ask the admin to
reconstruct a job from parts; at a site where the operator is the adversary a mis-composed
role is a security bug.
**Decision (three moves; the grid stays the enforcement layer — no guard semantics change for
the booth).**
- **Move 1 — each desk's money is guarded by that desk's own permissions.** The manifest
declares `tillGuards { read, shift, cash }`: booth = `shift:read` / `shift:create` /
`drawer:create` (parking's own, unchanged); carwash = `carwash:read` / **`carwash:cash`** (new)
/ `carwash:cash`. Shift + drawer routes resolve the guard FROM THE TILL
(`requireTill(kind)`), so a wash role holds no `shift:*` at all and cannot touch the booth by
construction; a role that should work both simply holds both. Replaces the one-day-old
`session:read` borrowing (`tillPermission`), which is deleted. `/api/shift/tills` lists the
tills a role may *read* with a `canWork` flag; history and movements without a till filter
return the union of the role's readable tills (admin scopes `shift:cash` / `drawer:review`
unchanged).
- **Move 2 — jobs on top of the grid.** Manifest `jobs[]` = named permission bundles: parking →
*Booth operator*, *Booth supervisor*; validation → *Merchant*; carwash → *Wash operator*. The
roles composer offers the jobs of the EFFECTIVE modules as one-click chips (add / remove the
bundle), with the grid kept as the fine-tune view, and LINTS the result: **mixes desks** (the
role may open more than one till) and **partial job** (holds a module's read permission but
not the rest of its job — e.g. a desk that can look but not create). Warnings, not blocks: the
admin is not the adversary, but must see what they composed.
- **Move 3 — the live feed follows the same rule (user: "The user should have websocket for
live events. This does not mean it can read the /reports section.").** The socket is no longer
gated on `report:read`. A role may connect if it holds ANY watch permission
(`event:read`, `session:read`, `device:read`, or an effective module's `feedPermission` —
carwash: `carwash:read`), and each pushed message is FILTERED per role: a ledger event needs
`feedPermissionFor(type)` (the owning module's, else `event:read`); occupancy needs
`session:read`; device / printer / lane / radar need `device:read`; plate backfill needs
`session:read`. So the wash desk gets a live queue without the booth's ledger, and the booth
operator keeps a feed without reports. `report:read` now means exactly the reports screen.
**Rejected.** Scoped permission strings (`shift:create@carwash`) — changes the `Permission`
type everywhere for what a manifest lookup expresses; a per-module copy of the shift/drawer
resources — the till already IS that copy. Role *templates stored in the DB* — jobs are code
(they change with the module), roles are data; keep that line.
**Status.** Moves 1, 2 and 3 built 2026-09-05 (see the Tills as-built below and [[shift]]
§Tills). Open: whether `booth-supervisor` should carry `subscription:*` by default; whether a
job should be *re-applicable* after a module update (today a chip only adds/removes the bundle
as it is now); an audit `config_change` on role edits.
## Tills: shifts per money-taking module — BUILT (raised + built 2026-09-05)
**The problem, found on the first wash-desk review.** [[shift]] is a single **site-wide**
accountability period with one drawer, implicitly the booth's. A bay payment today (a) requires
the *booth's* shift to be open and (b) folds its cash into the *booth's* expected drawer. So the
booth operator's Z-report comes up short by exactly what the wash operator holds, and the wash
operator — who neither cares about nor belongs to the park shift — has no Z-report at all. That
is the opposite of what the [[threat-model]] wants: the counted-vs-expected moment is the one
control against the unrecorded-wash vector, and it must sit with the person holding the cash.
**Decision (user: "go ahead and start building it", 2026-09-05):** a shift belongs to a
**till**, not to the site.
- `booth` is the till that exists today. A money-taking module declares its own till in its
manifest (`carwash`; a future `bar`). Two shifts may be open at once — one per till — each
with its own operator, opening float, cash in/out, expected drawer and Z-report.
- Every money event names its till: parking `payment` (and the booth-paid wash riding it as
`chargeLines`) = `booth`; `carwash_payment` at the bay = `carwash`. Drawer fold and
Z-report filter by till. Ledger events without a `till` field are booth events, so history
verifies and folds unchanged.
- The header shift button stays the booth's. The wash desk gets its own shift control (open,
cash in/out, Z-report — the same ceremony); **"take money at the bay" requires the
`carwash` shift to be open**, not the booth's.
- Rejected: no shift for the wash, reconciling from the per-operator report — it throws away
the counted-vs-expected control, which is also what the bay-camera signal will reconcile
against later.
- Cost: ~a day in the core `ShiftService` (till on shift + payment events, folds, per-till
Z-reports), a shift control on the wash desk, tests. Modules then get a drawer for free.
### As-built (2026-09-05)
- **Shared:** `TILL_IDS = ["booth", "carwash"]`, `TillId`, `BOOTH_TILL`, `isTillId`, and the
one rule everything reads through — `tillOf(payload) = payload.till ?? "booth"`.
`ModuleManifest.till?` (parking → `booth`, carwash → `carwash`); `tillsOf(effective)` = the
tills addressable at a site (booth + each effective module's). `LedgerPayload.till?`.
- **ShiftService** (`apps/server/src/shift-service.ts`): every public method takes a `till`
defaulting to the booth — `open/close/currentOpenShift/openShiftFor/requireOpenShift/
currentReport/drawerBalance/listShifts({till})/listOperators(till)/recordVoucher({till})/
movementsWithStatus({till})`. `shift_open` and `shift_z_report` payloads carry `till`; the
drawer fold, the window summary (payments **and** vouchers) and the shift-boundary scan all
filter by `tillOf`. Single-open is **per till**; no cross-till rule (a small site's one
person may hold both). Z-report/voucher slips print an `Arka: Lavazhi` line off the booth
only, so booth slips stay byte-identical.
- **Producers stamp their till:** `PayStation.pay()` (both parking paths) and the
subscription sale write `till: "booth"`; `carwash_payment` writes `till: "carwash"` and
`payAtBay` requires the **carwash** shift (`409 no_shift` now also returns `till`).
- **Routes:** `GET /api/shift/current?till=`, new `GET /api/shift/tills` (every till's state
in one read), `GET /api/shift/report?till=`, `POST /api/shift/open|close { till }`,
`GET /api/shifts?till=` (+ `tills` in the answer), `POST /api/drawer/movement { till }`,
`GET /api/drawer/movements?till=`, `GET /api/drawer/balance?till=`. `parseTill()` (server
`modules.ts`) answers `400 bad_till` for an unknown till or one whose module is off.
- **Web:** `useShift(till)` (key `["shift","current",till]`, under the WS-invalidated
prefix); the header `ShiftButton` moved to `ShiftControl.tsx` and takes a `till` — the
header renders the booth's, the **wash desk renders `till="carwash"`** with "Wash drawer
now" and gates *Paid cash/card* on **my** wash shift; the shift hub lists every open shift
(one per till) with Booth/Wash badges, a till filter and a start button per idle till; the
drawer hub has a till switch (only when the site has >1) scoping every panel.
- **Tests:** 6 in `shift-service.test.ts` (per-till single-open, requireOpenShift per till,
money folds into its till only, vouchers per till + separate carry-forward, close per till,
history/filter + pre-till = booth); carwash bay test now proves the booth shift does *not*
cover the bay and that the wash Z carries the money while the booth Z does not. Verified
live: booth held by `admin` since July, `testadmin` opened + closed a wash shift around one
bay payment — wash Z: cash 500, booth untouched, drawer hub shows each till's own figure.
- **Till access = module permission (2026-09-05, same day).** `ModuleManifest.tillPermission`
(booth `session:read`, carwash `carwash:read`) + `tillsFor(effective, has)`; server
`accessibleTillsFor(db, roleId)` guards open/close/current/report and cash movements
(`403 till_forbidden`); `/api/shift/tills` lists only the role's tills. A wash role
therefore never sees or opens the booth's shift. A role that *should* work both simply
holds both permissions.
- **Landing per module (2026-09-05).** `WebModule.landing` (`/wash` for `carwash:read`,
`/validate` for `validation:create`); the index route lands on the booth iff
`session:read`, else the first module landing the role holds, else `/shifts`, else the
profile; every guard bounces to `/` (the resolver), never to the booth, and `/booth`
itself now requires `session:read`. The hard-coded merchant special case is gone.
- **Not done:** role presets from the manifest (a one-click "wash operator" role in Setup
→ Roles); a permission-scoped live feed for module desks (the WS is `report:read` only —
the wash desk polls, 5 s / 15 s).
**Follow-ups, closed 2026-09-06** (details on [[shift]] §Tills and [[printer-roles-failover]]):
the activity log is per till (`tillOfEvent`, `/api/events?till=`; a feed-permission role
reads its module's events without `event:read`); the booth Z-report carries
`chargesByModuleMinor` (a booth-paid wash is out of the ticket bucket, printed
`Lavazh (në biletë)`); the wash till's slips print on a `wash-desk` printer, falling back
to the booth's.
## Review log — issues and ideas from the first hands-on pass (2026-09-05)
Recorded so the reasoning survives; each item's fix is in the As-built sections above.
1. **"Parking sponsorship" → "Zbritje parkimi" / "Parking discount".** Wording.
2. **Discount modes for the wash.** Owner-level needs: free *during* the wash (+ tolerance),
and "parking fee − wash price, floored at 0". Both added as wash-only modes resolved at
done. The typed-amount mode is the only one where the operator picks the money (highest
fraud exposure; kept for the Bar behind cap + per-day + attribution, hidden for the wash);
percent is a Bar use, not a wash one (hidden for the wash).
3. **Price-matrix cells for new rows were disabled until save** (no id yet). Fixed with a
two-request save behind one button.
4. **"Free until done" comped a 74-day stay** (ticket 92498375903, 1797h) — the credit was
anchored at entry. Re-anchored at the order's intake: only the wash window (+ tolerance)
is credited. Also: long durations now render `Xy Xd Xh Xm` everywhere.
5. **The desk needs to see finished washes.** Added a "Finished" list (done + paid, or
voided; newest first; who closed it; reason on void) under the queue.
6. **Wash operators vs the park shift** → the tills requirement above.
## 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.
- ~~**Tills**~~ — **built 2026-09-05** (above). Left: per-till activity log, wash bucket on
the booth Z, a printer role for the wash desk.
- 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