2aa1045ddc
Build & push images / images (push) Successful in 2m51s
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
568 lines
40 KiB
Markdown
568 lines
40 KiB
Markdown
---
|
||
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).
|
||
|
||
**Known follow-ups.** A shift's *activity log* (right pane of the hub, Drawer "today") is
|
||
still a time window over the whole chain, so a booth shift's log shows wash events in that
|
||
window (money figures are per till; the log is not). A separate wash bucket on the booth's
|
||
Z-report (booth-paid washes ride `chargeLines`) is still open. Bay slips print on the booth
|
||
printer until a wash-desk printer role exists.
|
||
|
||
## 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
|