The operator's category choice is a hypothesis, not truth (user, 2026-09-06): each wash
order with a vehicle read queues a package for a trusted reviewer over the private overlay
(Netbird); the verdict becomes the phase-B training label and the per-operator error rate.
wiki/concepts/vision-review-outbox.md.
- Boxes: the vision service returns the vehicle bbox; snapshot.ts stores the vehicle and
plate boxes on the read as FRACTIONS of the analysed frame (the stored snapshot is a
downscaled copy); vehicleForIdentity() returns them.
- carwash_review_outbox (migration 0031) + review-outbox.ts: crop = detector box + 8 %
margin, ≤ 640 px, plate blurred in place from the plate box; payload carries a
pseudonymous booth id and a keyed operator hash — no site name, no plate, no OSD, no
bystanders; multipart POST with a per-booth bearer; 2xx → sent (image dropped);
400/404/413/415/422 → abandoned; anything else → backoff 1 min·2^n capped 6 h; voided
orders and items older than 14 days abandoned unsent. Nothing queued while unconfigured.
- Enqueue is fire-and-forget off the intake path in createOrder; the loop runs every
CARWASH_REVIEW_INTERVAL_SEC (60) and stops on close.
- GET /api/carwash/review/status (site:read) + a "Remote review" line in Setup → Car wash.
- Env CARWASH_REVIEW_URL / _TOKEN / _BOOTH_ID (all three or off) documented in
.env.example and forwarded by compose.
- Tests: review-outbox.test.ts (crop + blur on a synthetic frame, config/pseudonyms,
queue/drain/backoff/abandon, through the app). Wiki: new concept page, index,
venue-modules As built, log. The collector is not built.
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
The vocabulary is a code constant (the model's contract), the site only maps it; a chip
reading VETURË beside a site category named Veture blurred exactly that (user, 2026-09-06).
Translation stays as the tooltip; a hint names where the list lives.
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
Fills /analyze vehicle.body_type + confidence (car / motorcycle / bus / truck from COCO,
mapped to the shared vocabulary) for the Car Wash desk's category suggestion
(venue-modules.md §Vehicle category from vision). Advisory: the operator decides, a
confident downgrade is flagged, nothing is gated on it.
- vision_service/vehicle.py: pure numpy/cv2 letterbox (pad 114, raw BGR), stride-grid
decode, class-agnostic NMS, one vehicle per frame (the box holding the plate's centre,
else the largest); YoloxVehicleDetector on onnxruntime CPU, 2 intra-op threads.
- recognizer.py: WithVehicle composes the stage over any plate recognizer (stub included);
a failing stage yields vehicle=null + a "vehicle: …" note in /health.detail — never
costs the plate read. model_version reads "<plate>+yolox:yolox_s.onnx@640".
- settings: VISION_VEHICLE_MODEL_PATH (unset = off), _INPUT_SIZE (640), _MIN_CONFIDENCE
(0.4, the detector's floor; the flag threshold is site config).
- Dockerfile bakes yolox_s.onnx (best-effort curl at build; no network → stage off) and
sets the path; compose forwards it (empty = off); .env.example documents it.
- Measured on four real dev entry frames (DS-2CD1047G3H, 2560×1440): car at 0.83–0.88 in
~240–330 ms; empty lane with a person → none.
- tests/test_vehicle.py: decode/NMS/pick/letterbox on synthetic tensors, the composition,
and a missing-model /health. Wiki: opencv-anpr-service, venue-modules, log.
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
The app plumbing for venue-modules.md §"Vehicle category from vision"; the model is the
open half (no bundled recognizer emits body_type yet, so the desk shows nothing until
phase A lands in the vision service).
- Shared: VEHICLE_CLASSES vocabulary, VehicleRead, CARWASH_VISION_THRESHOLD_DEFAULT,
reason code carwash.categoryDowngrade; settings/order/lookup views carry the read.
- Vision contract: /analyze vehicle.body_type + confidence (service schema); the Node
client normalises to the vocabulary and drops the rest.
- Record: snapshot.ts stores the read in the plate's device_events row (or its own when
the plate was unreadable); vehicleForIdentity() resolves it like the plate.
- Car wash: carwash_categories.vision_classes (site mapping "car, sedan → Vetura"),
carwash_config.vision_threshold (signed config_change when it moves), four vision
columns on orders — migration 0030. Lookup returns vision + suggestedCategoryId.
- Desk pre-selects the mapped category and shows the read + snapshot thumbnail; Setup
offers class chips per category and the threshold. Operator decides.
- Flag: a read at/above the threshold whose mapped category prices HIGHER than the chosen
one signs one `anomaly` (both categories/prices, operator, snapshot) and stores its id on
the order. Equal/upgrade/unsure/unmapped → nothing. Recorded only, never blocks, no
reason prompt (user, 2026-09-06).
Tests in carwash.test.ts; wiki venue-modules (As built), opencv-anpr-service, log.
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
Closes the permissions-matrix loose ends (venue-modules.md §Permissions matrix):
- `role_jobs` (migration 0029): a role stores the manifest jobs it was composed from
(chips on at save + any bundle fully present). `jobById` / `jobsBehind` in
@parking/shared surface a followed job whose bundle grew past the role in a later
release; the roles list shows a "behind <job>" badge with a one-click "Update to job"
(the union, nothing removed) and the editor lints it. Never a runtime union: the grid
stays the explicit enforcement layer and an update never widens a role without a click.
- Every role create/update/delete appends a `config_change` (`role.<id>`, prev/value =
name + sorted permissions + jobs, operator); a no-op resave signs nothing. roleRoutes
now takes the ledger.
- booth-supervisor already carries subscription:*; the stale open note is closed.
Tests: routes/roles.test.ts. Wiki: venue-modules status, local-jwt-auth, log.
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
Closes the three known follow-ups of the Tills decision (venue-modules.md):
- Activity log per till: `tillOfEvent(type, payload)` in @parking/shared (money events
by payload till, other events by their owning module's till, everything else booth),
applied by `/api/events?till=` in SQL and passed by the hub log, the Drawer "today"
panel and the booth feed (history + live pushes). The events route admits a role that
holds a module feed permission without event:read and returns only that module's
event types — the live-socket rule.
- Booth Z-report: `chargesByModuleMinor` sums the chargeLines on the till's payments by
module; the ticket bucket excludes them (Bileta = parking only); printed
"Lavazh (në biletë)" only when any was taken. The wash till's slip prints "Lavazh:".
- Printer role `wash-desk`: the wash till's Z-report and vouchers print there, falling
back to the booth printer; nothing falls back to the desk. `printerRoleOf()` is the
one reading of the role field (the entry/booth loaders treated any non-booth role as
an entry dispenser). Footer label "at wash desk".
Also: `GET /api/carwash/settings` opens to carwash:read OR site:read (new
requireAnyPermission) — the Wash operator job could not load the desk's category and
service pickers. Tests for all four; wiki (shift, printer-roles-failover, venue-modules,
log) updated.
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
A site entitled to parking,carwash had the wash silently dropped as dependency-broken.
The validation program routes (compose/read) leave the validation module gate; the
merchant scan routes (mine/lookup/apply/void) stay behind it.
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
The Komodo stack env alone is compose interpolation input; only variables in the
service's environment: block reach the container. Without it every booth on 55d6242
had Car Wash entitled (unset = every module).
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
Permissions matrix rethink (wiki/decisions/venue-modules.md §"Permissions matrix",
open-questions #16) — the grid stays the enforcement layer:
- Move 1: each desk's money is guarded by that desk's own permissions. Manifest
tillGuards {read, shift, cash}: booth = shift:read / shift:create / drawer:create
(unchanged), carwash = carwash:read / carwash:cash (new). Shift + drawer routes
resolve the guard FROM THE TILL (requireTill); a wash role holds no shift:* and cannot
touch the booth by construction. Replaces the session:read borrowing (tillPermission).
/api/shift/tills lists the role's readable tills with canWork; history/movements
without a till filter return the union of readable tills.
- Move 2: jobs — manifest permission bundles (booth-operator, booth-supervisor,
merchant, wash-operator) as one-click chips in Setup → Roles, with "mixes desks" and
"partial job" lints (warnings, never blocks).
- Move 3: the live WebSocket admits any watch permission (event/session/device read or
a module's feedPermission) and filters every push per role; report:read is the
reports screen only.
Auth: the token's roleId is only a hint — refreshRole() after every jwtVerify resolves
the user's CURRENT role (cached, bumped on role/user writes), so reassigning a user's
role applies on the next request and a deleted user's session ends with 401.
Tests: till guards + look-only role, feed rules, every job's permissions exist, role
reassignment without re-login. 353/353.
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
Car Wash — the pilot venue module (wiki/decisions/venue-modules.md):
- Master data (categories × services price matrix) at /setup/carwash; the desk at /wash
(ticket lookup → order; open queue oldest-first: Done / Paid cash / Paid card / Void;
Finished list). Orders freeze names + price; their life is signed (carwash_order,
carwash_payment). Migration 0027.
- Where money is taken is a SITE setting (carwash_config.pay_at, migration 0028, signed
config_change on a flip) — no per-order radio; a stale client is refused (409).
- Core seams: PayStation charge providers (a booth-paid wash rides the parking payment as
chargeLines) + applyValidation() shared with the merchant route. A bay-paid, done wash
signs the $0 parking payment so the exit reader releases the car.
- "Parking discount" modes for the wash: free while the wash runs (+ tolerance) and wash
price off the fee (floored at 0), resolved at done and anchored at the order's intake
(the entry-anchored version comped a 74-day stay); typed-amount and percent hidden for
the wash. Long durations render y/d/h/m.
Tills — a shift belongs to a till, not the site (wiki/concepts/shift.md §Tills):
- TillId booth|carwash; every money event names its till (absent = booth, so the chain
re-folds identically). ShiftService is per till: single-open, folds, X/Z-reports,
vouchers, carry-forward. A bay payment needs the carwash shift.
- Working a till needs that till's module permission (manifest tillPermission; 403
till_forbidden); /api/shift/tills lists only the role's tills.
- Web: ShiftButton per till (header = booth, wash desk = carwash); shift hub lists every
open shift with till badges + filter; drawer hub switches tills.
Modules: landing per module (index route resolves booth → module landing → shifts →
profile); guards bounce to "/", /booth needs session:read.
Tests: carwash e2e suite (settings, intake, booth/bay paths, modes, void, gate, pay-at
policy, till permissions), 6 per-till shift tests; suite green (1 pre-existing flaky
backup test under the parallel run).
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
Groundwork for the Car Wash pilot (wiki/decisions/venue-modules.md, build-order
steps 1 + 3). No Car Wash code yet; validation is the first module behind the
seam, unchanged in behaviour.
- @parking/shared: MODULE_IDS, ModuleManifest, MODULES (parking required;
validation dependsOn parking), parseEntitledModules / resolveModuleActivation
/ effectiveModules as pure functions.
- DB: site_config.modules_json (migration 0026, hand-written + journal;
additive, nullable = everything entitled).
- Server: modules.ts (entitledModules from MODULES_ENTITLED env, activated
from site_config, effective set, requireModule preHandler → 403
module_disabled); modules/index.ts registers folder-based modules by
iterating the registry (modules/validation); site-config GET exposes
modules/modulesEntitled/modulesActivated, PUT takes the full desired set,
enforces entitlement + dependency rules (400 with reason) and signs one
config_change per module that actually flips; /api/auth/me carries the
effective set; validation routes guarded requireModule → requirePermission.
- Web: lib/modules.ts + modules/{index,validation}; router.tsx spreads
WEB_MODULES into nav + route tree (validate route no longer named there);
Setup → Site "Modules" panel (required shown disabled, dependencies as
hints, server refusal shown verbatim); validation sections + programs fetch
gated on the module; App invalidates the router whenever the session
changes (route-context consumers only re-read on navigation — the nav was
stale after a flip, and after every other setUser too).
- Lavazh validation station retired (STATIONS = ["bar"]; rows untouched).
- Deploy: MODULES_ENTITLED=parking,validation explicit in both booth stacks;
documented in .env.example.
- Tests: modules.test.ts (7); suite 329/329; web build clean; Playwright
round-trip on /setup/site verified live.
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
Decision (user, 2026-09-04) after the first successful self-update
(v0.1.6 → v0.1.7): a .deb update runs pkexec dpkg -i and asks for an admin
password the operator does not have — that prompt is the intended gate.
The AppImage was tried as the no-root path and aborts on the 26.04 booth
(bundled 24.04 glib/WebKitGTK vs host gvfs/Mesa: EGL_BAD_PARAMETER), and it
discards the distro-maintained WebKitGTK the platform decision rests on.
Passwordless polkit for dpkg is root for the operator — rejected.
- update.prompt (en + sq) now says the install needs the administrator
password.
- desktop-shell-tauri.md: decision, evidence, rejected alternatives, and the
deferred fleet-grade option (root systemd timer in the .deb, minisign-
verified, notify-only in-app).
- standing-decisions.md: ship the .deb; runtime backend; updates admin-only.
- appliance-provisioning.md: drop the stale "hardcoded to localhost" note.
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
tauri-plugin-updater resolves the download target as {os}-{arch}-{installer}
first (linux-x86_64-deb — the bundler stamps the installer type into the
binary, verified with `strings` on a local .deb) and only then bare
linux-x86_64. Our manifest carried only the bare key, pointing at the
AppImage. A .deb install therefore downloaded the AppImage, verified its
signature, then failed install_deb()'s is_deb check with
InvalidUpdaterFormat — after the download, before any relaunch. This, not
version drift or swallowed errors, is why v0.1.0→v0.1.6 never self-updated.
latest.json now carries linux-x86_64-deb, linux-x86_64-rpm (when built) and
linux-x86_64 (AppImage), each with its own .sig. A .deb update ends in a
polkit password prompt (pkexec dpkg -i) — the intended admin gate on a
root-installed package. README + wiki updated; wiki also records the v0.1.6
LIVE field verification.
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
The v0.1.4 Origin fix cleared only the first of two gates in /api/ws's
preHandler. The second, req.jwtVerify(), reads the HttpOnly cookie — which
tauri-plugin-websocket (a bare tungstenite client, no cookie jar) can never
send. Every desktop handshake 401'd and use-live-feed reconnected every 10s
(confirmed in the park-2 server log).
- routes/ws.ts: POST /api/ws/ticket (cookie + CSRF auth) mints a 30s,
single-use, in-memory ticket; the WS preHandler accepts it via an
x-ws-ticket header after the Origin check, then the same report:read
role check. Browser cookie path unchanged; JWT stays out of JS.
- platform-ws.ts: fetch a ticket before connect, send it with the Origin
header; connect failures now go through logClient (rate-limited).
- logger.ts: flush read the CSRF token from document.cookie, null on
desktop, so every desktop POST /api/logs 403'd and was dropped silently —
no desktop client log had ever reached app_logs. Stash moved to a
dependency-free lib/desktop-csrf.ts shared by api.ts and logger.ts.
- backend-config.ts: ConnectScreen probe uses the unauthenticated /health
(now also returns app: "parking-system") instead of accepting any 401.
- README: local-AppImage release gate — tauri dev runs at
http://localhost:5173, not tauri://localhost, so none of these
origin-dependent bugs reproduce there.
- wiki: new section + log entry; four citation corrections.
Requires the server image with this commit deployed before the new desktop
build connects (the ticket endpoint must exist).
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
The desktop shell is one generic .deb/.AppImage distributed via
mca/public_releases, not built per-booth, but the backend origin was baked
in at build time (VITE_API_BASE, hardcoded to http://127.0.0.1:3000) — the
same installer could never point at a different appliance without a
rebuild.
Adds ConnectScreen (shown before Login in Tauri when no backend is saved),
backed by tauri-plugin-store persisting the operator-entered URL across
restarts. CSP's connect-src tightens to 'self' only — all backend traffic
already routes through tauri-plugin-http/websocket, which run Rust-side
and are outside connect-src's reach anyway — and the real access boundary
moves to capabilities/default.json's http:default scope, wildcarded so an
operator-chosen host is actually reachable. Adds a "Change server" control
in Setup (desktop-only) to repoint an already-configured install.
While tracing the desktop auth path for this: tauri-plugin-http's fetch()
runs through Rust's reqwest, which keeps its own cookie jar separate from
the webview, so document.cookie on tauri://localhost never sees the
parking_csrf cookie the server sets (open upstream bug,
tauri-apps/tauri#13045/#11518). This means the desktop app has likely been
silently sending no CSRF header on every mutation since the shell was
first built — pre-existing, independent of this change. Fixed by having
sessionView() (routes/auth.ts) also echo the CSRF value in the login/me
JSON body; the desktop client stashes it in memory and echoes that instead
of reading document.cookie. assertCsrf() itself is untouched.
Verified end-to-end against a real LAN-bound dev server: login returns a
csrfToken matching the cookie, a mutation using the body-sourced token in
X-CSRF-Token succeeds (200), and the same mutation without it still
correctly 403s.
Nothing displayed which desktop build was actually installed — debugging
a stuck update meant inferring the current version backwards from the
update prompt's target version. Added DesktopVersionBadge (next to the
existing server-side VersionBadge) using @tauri-apps/api's getVersion(),
the real running app version baked in from tauri.conf.json. No-ops in a
browser. Exported inTauri() from origin.ts instead of redefining it again.
console.error/console.warn only forward to the server when the client log
level is debug/trace (default: info) — the earlier error-logging fix never
actually surfaced anything, and a real update failure produced zero logs
anywhere. desktop-updater.ts now calls logClient() directly, unconditionally,
plus download-progress events. Also documents the resource-sync-park-systems
branch misconfig (pointed at dev, Stacks are stage-tier) found while chasing
this — full writeup on fleet-deployment-komodo.md.
Login worked after the mixed-content fix, but the live feed 403'd silently:
tauri-plugin-websocket's connect() runs on Tauri's Rust side, not inside the
webview page, so it never auto-attaches Origin the way a browser WebSocket
would — routes/ws.ts's anti-CSWSH check rejects a missing Origin before
auth. platform-ws.ts now sets Origin: tauri://localhost explicitly.
Also fixes a second, independent gap the above alone wouldn't have caught:
komodo/resources.toml's booth Stacks had WS_ALLOWED_ORIGINS= empty in
production despite .env.example documenting it as required for desktop.
Needs a Komodo sync + redeploy to reach a live booth.
Fixing VITE_API_BASE got login to build a correct absolute URL, but it still
failed with WebKit's generic "Load failed" — WebKitGTK treats tauri://localhost
as a secure origin, so http://127.0.0.1:3000 (and ws://) from inside it is
blocked as mixed content, a WebKit limitation CSP's connect-src can't override.
Added tauri-plugin-http (genuine fetch() drop-in, wired via a new
platformFetch() in origin.ts, used by api.ts + logger.ts) and
tauri-plugin-websocket (not a drop-in — adapted behind a native-WebSocket-
shaped interface in the new platform-ws.ts so use-live-feed.ts needed no
changes). Both route through Tauri's Rust side instead of the webview's own
fetch/WebSocket. Capabilities scoped to 127.0.0.1:3000/localhost:3000, matching
the existing CSP allowlist.
v0.1.1 was tagged but tauri.conf.json's own "version" field (what Tauri
bakes into the bundle filename/internal version) stayed at 0.1.0 — the
signed binary didn't match what latest.json claimed to describe, so every
update download failed signature verification. desktop-updater.ts's single
catch{} swallowed that identically to "offline", so it looked like nothing
happened at all. release.yml now syncs tauri.conf.json's version from the
git tag before building; the updater now logs a real post-accept failure
instead of silently reverting.
apps/web/.env.production's VITE_API_BASE went empty in 96fd97e to fix the
booth/browser same-origin case, but the desktop build shares that file and
was never given its own override — login broke with WebKitGTK's "The
string did not match the expected pattern." (a relative fetch() URL with
no base, from tauri://localhost). beforeBuildCommand now sets
VITE_API_BASE=http://127.0.0.1:3000 inline for the desktop build only;
verified both builds independently produce the right output.
Still at the scaffold default 0.0.0 with no v* tag ever cut. Bumping so a
v0.1.0 tag can exercise release.yml (and the new public_releases mirror
step) for the first time.
The updater endpoint pointed at mca/parking_solution's own Gitea "latest
release" redirect, but that repo is private and field appliances have no
Gitea credentials — every update check was silently failing. release.yml
now mirrors signed installers to mca/public_releases (public, installers
only) under a fixed desktop-latest tag; tauri.conf.json points there.
Rejected embedding a read token in the app instead, given the booth-operator
threat model.
Also: make the appliance-provisioning root_directory gotcha impossible to
skim past (boxed callout + explicit next-step pointers), after it caused a
second missed step on the park-2 install.
CI already computes <branch>-<short-sha> for image tags but never
surfaced it anywhere reachable from the app, so there was no way to
tell what's actually deployed on a booth without cross-referencing
komodo/resources.toml's TAG by hand.
Thread it through: CI passes BUILD_VERSION as a Docker build-arg,
the Dockerfile captures it as a runtime env var, GET /api/version
(gated by the existing site:read permission) exposes it, and the
Setup page's tab bar shows it right-aligned, muted, absent entirely
on a local/dev build with no CI-supplied value.
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
BackupService tracked last-success/last-error as plain in-process fields
and scheduled the daily backup via setInterval measured from process
start — so any server restart (deploy/crash/OOM/reboot, routine under
`restart: always`) silently reset the admin UI to "last successful
backup: Never" and drifted the actual cadence, independent of whether
backups were writing correctly to disk (they were — a real field
incident at park-buzi showed 7 valid rotating backups on disk with the
status stuck on "Never").
Persist last-success/error to new site_config columns (migration 0025)
and add BackupService.isDue(), computed from the persisted timestamp
instead of process uptime; server.ts now polls every 15 min and lets
isDue() gate the actual run. No API/UI contract change.
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
A user whose role has only validation:create (the bar/lavazh validator) made
the shell misbehave: useLiveFeed() connected /api/ws unconditionally, the
server's report:read guard 403'd the upgrade, and the capped-backoff
reconnect hammered it forever — a 403 in the server log every few seconds.
Gate the socket on report:read (mirrors routes/ws.ts WATCH_PERMISSION) and
render StatusDot / ShiftButton / DeviceFooter only with their backing
permissions (report:read / shift:read / device:read), so a merchant's shell
is just the nav + their /validate screen, with zero doomed requests.
Claude-Session: https://claude.ai/code/session_01YYkpEsLmoQPaize5ec3oUm
In-park merchants discharge customers' parking: a merchant user scans the
ticket on their device (/validate; validation:create + program↔user binding)
and applies their program — comp / first-N-minutes free / amount-off (capped,
typed at scan) / percent. All money stays at the booth: the quote folds live
validations in a canonical order (timeCredit → percent → fixed → comp, net
floors at 0, Σ lines ≡ gross − net), the payment records gross/discount and
CONSUMES the validation ids (an overstay's fresh period never re-applies
them), the receipt prints the gross → lines → net story, and the Z/X-report
carries discountTotalMinor leakage. Every apply/void is a signed, attributed
ledger event (refId = append-only void); program config is /setup/site master
data (Bar/Lavazh checkboxes + right-column panel, tabs when both) whose saves
sign config_change. Migration 0024 + reset-db drift-guard entries; 8 route
integration tests + priceSession fold suite.
See wiki/concepts/validation-discounts.md for the full design record.
Claude-Session: https://claude.ai/code/session_01YYkpEsLmoQPaize5ec3oUm
The pay modal rendered entry via formatRelativeDateTime (relative day, no
seconds → "Sot 19:25") and exit/now via the legacy formatTime (raw
HH:MM:SS, no day → "19:25:44") — inconsistent on both day context and
seconds. Added a { seconds } option to formatRelativeDateTime and routed
all four call sites (entry, exit, live now, alreadyClosed toast) through
it, so every row reads "Sot 19:25:44". Removed formatTime — the last raw
toTimeString() helper and the source of the mismatch; BoothPayModal was
its only caller.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
A line identical to the last persisted row (level+source+message+path)
within a 5-min refreshing window updates that row — context._repeat counts
the fold, _firstAt keeps the first occurrence, createdAt tracks the latest
so the storm stays at the top of the newest-first viewer. A continuous
storm stays ONE row however long it rages, so it can't evict unrelated
history via the 50k row cap or grind the appliance disk. LogsViewer badges
coalesced rows ×N (tooltip: count + first occurrence, sq/en). In-memory
last-row cache only; a pruned-under-us row falls through to a fresh insert.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
An unreachable controller rejects the UDP send instantly, and #pump's
failure re-pump retried inline: a tight loop logging hundreds of identical
errors per minute (park-buzi, 2026-07-07). Failed sends now arm a 1s→30s
exponential retry (reset on success); desiredOn keeps tracking the truth
table meanwhile and the armed retry converges to it. Logging is
rate-limited: first failure of a streak in full, then one summary/minute,
one info line on recovery. #finalOff waives the backoff so the last-gasp
OFF on drop/shutdown still gets an immediate try.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
park-buzi field observation: after a power cut the Hikvision cameras
reboot at the 1970 epoch (no/dead RTC battery, no NTP) and stay there
until a human logs into the web UI (which silently pushes the browser
clock) — corrupting the snapshot OSD timestamps (the evidence trail) and
ANPR push times meanwhile.
The host is the site's time authority (offline-first, no NTP infra):
- Device monitor triggers a sync at each camera's offline→ready edge —
exactly the power-restored moment — plus a 24h backstop; the attempt
is stamped before the async call so a failing camera retries at
backstop cadence, never every poll.
- HikvisionCamera.syncClock: GET /ISAPI/System/time; drift ≤60s → leave
alone; beyond (or unparseable = infinite drift) → PUT timeMode=manual
with the site wall-clock now WITH explicit utc offset
(localIsoWithOffset), echoing the camera's timeZone verbatim — correct
the clock, never fight its tz/DST config.
- Jumps >1h (the power-cut signature) log warn (persisted to app_logs);
small corrections info. Capability-guarded (isClockSyncable) —
hikvision only; dahua's CGI has no such endpoint.
- http-digest generalised to digestRequest (GET/PUT/POST + body); the
handshake was already method-aware. digestGet delegates unchanged.
8 new tests: in-sync no-op, 1970 PUT shape (manual + host instant +
echoed tz), unparseable→sync, failed-set surfaces, dahua non-capability,
DST-both-sides pins on the offset formatter.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
The kernel numbers usblp nodes by plug/boot order (park-buzi's printer
is lp1); the wizard hardcoded lp0 in labels/default and the admin had to
shell in and `ls /dev/usb`. Now:
- GET /api/setup/usb-printers enumerates /dev/usb/lpN (visible via the
compose bind-mount) and enriches each with the printer's self-reported
make/model from sysfs ieee1284_id (readable through Docker's ro /sys).
- The wizard's devicePath becomes a SELECT of printers actually present
("/dev/usb/lp1 — Xprinter XP-K200L"): a fresh form preselects the
first real device; a saved-but-unplugged path stays selectable,
flagged "saved — not present now"; zero found falls back to free text
+ a check-the-cable hint.
- Transport option label no longer hardcodes lp0.
Wiki: printer-usb-transport marked HARDWARE-VERIFIED (lab 2026-07-07:
full slip + feed + cut over USB — parity with TCP).
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Second half of the printer/relay decoupling: the category section's
add-button gate ("add a controller first — a printer points at one of
its relays") blocked every non-access category while zero controllers
existed — hit on the lab bench (USB printer test, no relays on hand).
Printers don't bind (role + failoverRank route jobs), so the gate now
exempts them like the form's requirement already does.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Field failure on park-buzi: reset-db --users wipes the roles table and
points to seed-admin — which inserted the user with roleId "admin"
without recreating the role row (migration 0007 never re-runs), dying on
the role_id FOREIGN KEY. The script now upserts the built-in admin role
first (the row alone suffices — admin permissions resolve in code).
It also appends a SIGNED config_change (admin.passwordReset /
admin.seeded, operator console:seed-admin) via the server's compiled
EventLog + signer: a console seed/reset by the Linux admin can't be
gated by the app, but it stays attributable in the chain. Best-effort —
no build/signing key warns loudly and proceeds (locking an admin out to
protect an audit line would invert the priority). Both paths verified
against a scratch DB reproducing the post-reset state.
Runbook: appliance-provisioning §7e — lost app-admin password reset via
FORCE=1 (interactive preferred; sessions not revoked → rotate JWT_SECRET
if theft suspected); §7d notes the FK failure + self-heal.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
"ALL 740 / 3h 2m" gave no derivation. explainFee in @parking/shared runs
the EXACT computeFee walk with an optional trace collector — one code
path, so Σ line items ≡ the amount by construction (golden V1 regression
byte-identical; instrumentation changes no fee). Items: contiguous
same-price increment runs (time window · N × unit · tier-card name),
window-package occurrences, stepped day totals (top-tier repeat
flagged), daily-cap clamps as NEGATIVE adjustments, entry grace.
/api/tariff/simulate returns `breakdown` (null when settled); the lab's
Outcome panel renders the lined table with a rounding note (raw min →
billed min at the increment — answers "why does 3h 2m bill as 4h") and
a total row. Works against active/historical versions and drafts alike,
so a night-package draft can be verified line by line before publish.
Largely delivers the wiki's open "composer price preview" item.
4 new engine tests pin the sum invariant + item shapes (97 shared green).
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Ladder/flat prices are PER BILLING INCREMENT, but the form said only
"Çmimi / interval" — so changing the increment 60→10 silently multiplied
every price ×6 (operator walked into it). Now:
- Price headers name the real unit live: "Çmimi / orë" at 60,
"Çmimi / N min" otherwise (flat-mode radio label likewise).
- Amber warning whenever the increment ≠ 60: every price below is
charged per started N minutes, NOT per hour.
- Per-row "= X / orë" equivalence next to each ladder/flat price when
the tick isn't an hour — the multiplication nobody should do mentally.
- Example defaults are currency-scaled: ALL gets 200/100 ladder, 200/500
up-to, 2000 lost ticket (the old "2.00/1.00" euro-scale examples read
as 2 lekë/hour); EUR/USD keep 2/1/5/20. Threaded through empty forms,
new tier rows, and mode-switch templates alike.
Band DURATIONS stay in hours — real wall time, increment-independent.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Dates were a mix: catalog-formatted "25 Qershor 20:01" where screens used
formatRelativeDateTime, and browser-locale "7/6/2026, 9:34 AM" in ~20
places that called raw toLocaleString/-Date-/-Time-String. Unified:
- common.monthsShort in both catalogs (Jan/Shk/…/Qer/Korr/…/Dhj);
formatDate ("25 Qer", year only when not current), formatDateTime
("25 Qer 14:30", optional seconds), formatClock ("HH:mm", 24h) in
lib/format.ts. formatRelativeDateTime keeps Sot/Dje and switches its
older-dates branch to the same short months.
- Every raw toLocale* DATE call swept: shifts X-report line, plan
effective dates, sub version labels, drawer today feed, snapshot
tooltips, device footer checkedAt, event-detail timestamp (keeps
seconds — chain evidence), tariff composer active-since + version
sidebar. Number toLocaleString (thousand separators) untouched.
The catalogs in this commit also carry the keys for the two follow-up
commits (fee breakdown, composer increment labels).
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
"Cilën barrierë shërben kjo pajisje?" is load-bearing for readers and
cameras (which barrier a scan opens + inherited direction) but nothing
consumes it on a printer — print routing is role + failoverRank
(printer-routing.ts). The wizard applied the requirement to every
non-controller device, so adding a printer demanded a meaningless relay
pick that got stored as dead config.
Printers are now exempt: no requirement, the binding panel is hidden,
the binding is not persisted (a stale pre-fix one drops off on next
edit), and the device list shows the printer's ROLE instead of a bogus
amber "unbound". Server never validated it — no API change.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
The dashboard had generic BI views but nothing parking-shaped. Added:
- Occupancy step-area over the range with the configured capacity as a
red reference line. occupancyStart folds the ENTIRE prior ledger
(voided entries excluded, clamped ≥0); each series point carries
occupancyEnd. Answers "when are we near full".
- Entries heatmap hour × day-of-week (7×24, row 0 = Monday, site tz) as
a pure CSS-grid intensity map — weekday-vs-weekend at a glance, the
direct evidence for tariff windows. Replaces the flat hour histogram
(strictly contains it).
- Stay-duration histogram at tariff-shaped edges (30m/1h/2h/4h/8h/24h/
tail): where ladder/up-to breakpoints should sit.
- Voids + anomalies KPIs (accented when >0) — the look-closer counters
the signed chain exists for; peak-occupancy KPI (peak / capacity).
- Revenue bars stacked cash vs card (the drawer's money vs the bank's);
CSV export gains cash, card, occupancy_end columns.
Internals: localParts caches its Intl formatter per tz (was one new
formatter per ledger row); @parking/db re-exports lt/gt. 5 new tests.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
The lab redesign gave only the lab tab the published-history sidebar;
the composer page was expected to have it too. /setup/tariff now lists
every published version (name or effective date, active badge, currency)
on the right; clicking one loads it into the editor as the SEED for the
next publish — which always creates a new immutable version (the sidebar
hint states this), making "roll back to last month's prices" a two-click
republish while the history stays append-only.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
/drawer was record + review only: no current balance, no sight of the open
shift's incomings, no daily activity, no shift history. Rebuilt as a hub:
- Drawer now: the till's running balance (new GET /api/drawer/balance,
shift:read — exposes the service's existing drawerBalance(); the drawer
is one site-wide till, same exposure the X-report already had) with the
open shift's X-report breakdown alongside (float + takings + vouchers =
expected = balance) and a "This shift: ±X" figure (expected − opening
float — the shift's own contribution vs what it inherited).
- Today's cash activity: every cash payment + voucher since local
midnight from the signed chain, live, with day totals (card never
enters the till).
- Record + movements/review: the 2026-07-01 flow, unchanged.
- Closed shifts: drawer-focused history via the scope-aware /api/shifts
(float → takings ± vouchers → expected per shift).
Also: every shift open/close button (header, /shifts, pay modal, end-
shift confirm) now shows an animated spinner + dims while busy — the old
label-swap-only feedback read as a dead click when a shift open ran slow.
The slowness itself (drawer/shift reads fold the WHOLE chain, O(chain))
is recorded as an open item in wiki/concepts/shift.md with the fix
sketch: fold from the last z-report's signed expectedDrawerMinor forward.
No new ledger surface — one read-only endpoint; RBAC test added.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Currency was free text in the tariff editor (composer page + lab draft
modal — shared form) and the subscription plan editor; a typo could
publish an unknown code onto immutable versions. Both now offer a closed
select from lib/currencies.ts. An out-of-set code already stored on an
old record is appended as an extra option so it displays + round-trips
unchanged. Blank tariff form defaults to ALL (was EUR) — the site's
actual currency.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
The admin operator filter was a free-text input that broke three ways at
once: its visibility hangs off the query response (scope === "all") and
its value is part of the query key, so every keystroke started a new
query, data went undefined for the round-trip, and the input UNMOUNTED
mid-keystroke (lost focus, list blanking that read as a page reload).
Filtering also silently failed — the server matches the operator by
exact username, so partial text matched nothing.
- keepPreviousData on the shifts query: previous data (and scope) stays
live during refetch, so filter controls never unmount and the list
never blanks on preset/filter changes.
- The filter is now a <select> of operators that HAVE shifts: the server
returns the distinct list (signed z-reports + the open shift's holder)
on GET /api/shifts, admin scope only — operators still can't see other
names. Exact match by construction.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
The booth is an offline appliance — no webfont CDN — so the font ships
from public/fonts/chakra-petch: latin subset (covers en + sq ë/ç), the
weights the UI actually uses (400/600/700 + 400 italic, ~40 KB total),
SIL OFL license alongside the files. Chakra Petch leads all four family
tokens (mono/display/ui/body) with the previous stacks kept as fallback;
index.html preloads the two everywhere-weights so first paint doesn't
flash the fallback. Not a true monospace — .num/.tabular still request
tabular figures and columns verified aligned in the built app.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Experimenting used to mean publishing — churning the immutable version
history and risking real tickets pricing against a half-baked card while
the admin iterated. The lab is now a true sandbox:
- tariff_drafts table (migration 0021): MUTABLE by design — the one
exception to "editing publishes a version"; a draft prices nothing and
signs nothing. Drafts are validated + tz-stamped on save exactly like a
publish, so a saved draft always simulates and never fails at publish.
- CRUD under /api/tariff/drafts (list tariff:read, mutations
tariff:update); publishing a draft goes through the normal immutable
POST /api/tariff/versions path.
- Lab UI rebuilt: sidebar lists lab drafts AND the full published history
(click any to price against it); main pane cut to pure entry/exit
(ticket loader, payment, category inputs dropped); the composer form is
extracted to TariffEditorForm.tsx and reused in a modal (new drafts
prefill from the active card); per-draft Publish with confirm.
- tariff_versions.name (migration 0022): optional label stamped at
publish — carried from the lab draft, or typed in the composer's new
optional field — so history reads "Winter 2027", not UUID prefixes.
- Includes the composer UI + sq/en labels for the package mode (engine
landed in d9e6c13) and the "Flat price / hour" relabel.
5 new server integration tests (RBAC, roundtrip, validation, tz-stamp +
simulate + publish w/ name); server suite 288 green.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
The lab lived at /subscriptions/tariff-lab — the wrong neighborhood for a
tool that tests the rate card. /setup/tariff is now a small layout with
two sub-tabs (composer at the index, lab at /setup/tariff/lab) behind the
existing tariff:read gate. Old URLs (/subscriptions/tariff-lab and the
original /setup/tariff-lab) redirect, and the tariff-read-only redirect
branch on /subscriptions is gone with the tab.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
CI flake root cause (Gitea runner, anpr-entry.test.ts "records an advisory
anpr-skip"): the poll-until-confident loop was a plain
`while (Date.now() < deadline)` — zero iterations were possible when the
window elapsed between deadline-set and loop-entry (the tests run a 5ms
window; a slow runner loses that race). Zero attempts → no frame analyzed →
"gave up" → no anpr-skip row → assertion fails. Not a regression: nothing in
the recent merges touched this path; the race existed since the poll loop
was built.
The invariant is real beyond tests: on a sufficiently loaded booth the old
loop could silently drop a real car's detection the same way. The loop is
now do-while (exit via the existing breaks: confident read, or next tick
past the slid deadline/hard cap), so a detection ALWAYS analyzes at least
one frame.
New regression test forces ANPR_POLL_WINDOW_MS=0 (the CI scenario, made
deterministic) and asserts exactly one capture attempt + the recorded skip.
Suite 283 green.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Operator asked for bounded container logs (~2 months of history), human-
readable timestamps, and clarity on levels. Levels already existed (LOG_LEVEL
env → pino, default info; warn+ teed into app_logs, queryable at /setup/logs)
— the "level":30 / epoch-ms "time" in docker logs were pino defaults.
- server.ts logger: stamp ISO-8601 UTC time (timestamp fn) and level NAMES
(formatters.level) so `docker logs` reads human.
- log-service.ts pinoDbStream: accept BOTH level encodings (name + numeric) —
the label switch would otherwise have silently stopped warn+ persistence
into app_logs. New log-service-stream.test.ts pins both encodings, the
info-stays-stdout-only rule, and the never-throws fallback.
- docker-compose.prod.yml: json-file caps resized from 10m×3 (≈30 MB — days,
not months) to ≈2 months by volume: server 20m×30, vision 20m×10, proxy
10m×5. json-file rotates by SIZE; time-based isn't a driver feature —
comment says to revisit if `docker logs` holds under ~60 days.
- app_logs retention default aligned 30→60 days (LOG_RETENTION_DAYS still
overrides).
Wiki: app-logs.md gains the container-log store section (rotation, format,
LOG_LEVEL knob) + retention update; log.md entry.
Suite 282 green.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Two reader-hardening changes born from the park-buzi phantom-scan investigation
(empty pre-opening site, exit reader pushing sun-decoded garbage codes).
1. CHANNEL TAGGING — closes the printed-card-clone hole. The DT-008 push is
channel-blind (one opaque cardid from either engine) and SubscriptionFlow
matched by value only, so printing an RF card's UID (often written on the
card face, e.g. 86A158) as a barcode cloned the card. Now:
- Vendor tool sets output prefixes (QRCode "Q:", Card "K:"; server env
overrides READER_QR_PREFIX / READER_CARD_PREFIX).
- routes/qr-reader.ts strips the prefix and tags the read's confirmed
channel (DeviceReadEvent.channel optical|rf; kind qr|card). Enrollment
capture stores the BARE value. READ log lines carry ch=… (permanent
phantom attribution).
- SubscriptionFlow.match requires channel agreement: an optical decode may
not claim an rf credential (and vice versa) — refused + signed
sub.refused.channelMismatch anomaly (a clone attempt is a fraud signal).
- Unprefixed reads keep the legacy untagged shape and match as before, so
enforcement only bites where prefixes are deployed. Deploy server FIRST,
then set prefixes in the vendor tool.
2. STRUCTURAL FILTER — phantom decodes out of the signed feed (operator-
requested, reverses the earlier "record every probe" position — red
"who is exiting?" rows for NOBODY train the operator to ignore the feed).
read-dispatch.ts drops a no-match reader value that cannot possibly be a
credential we issue (no ticket Luhn shape, no SUB-/SUBSESS- prefix, not
confirmed-RF, not a plate) to UNSIGNED device_events telemetry
(unrecognizedRead:true). Deliberately WIDE plausibility: forged ticket
shapes, unknown physical cards, unknown SUB- codes all still sign the
normal refusal anomaly; enrolled credentials match before the filter and
can never be hidden. Works for legacy unprefixed reads too — the feed
cleans up on deploy, before any vendor-tool change.
Wiki: dingtian-dt008-reader.md records the clone hole + fix, the filter (as a
recorded position reversal), and the two device-side settings now part of the
credential contract (output prefixes + Card Input format, moving 6H→8H at the
next vendor-tool session; both live ON the device — re-apply after any
factory reset/swap).
Tests: qr-reader-channel.test.ts (prefix split, route tagging, bare-value
capture), subscription-channel.test.ts (channel agreement matrix + anomaly),
read-dispatch-filter.test.ts (filter boundary: phantoms dropped, probes kept,
enrolled never hidden). Suite 278 green.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V