125 Commits

Author SHA1 Message Date
julian f4b806a538 fix(collector,trainer): migrate an existing collector DB on open; trainer handlers answer 500 JSON
The reviewer host's collector.sqlite was created by an earlier build, before the
`kind` column. CREATE TABLE IF NOT EXISTS shapes only a new database, so every query
naming the column failed: the collector's /health (container unhealthy), every
booth ingest, and the trainer's readiness — whose stdlib server printed the
traceback and dropped the socket, which the collector could only render as
"trainer not reachable: fetch failed". Nine days like that.

- CollectorDb.#migrate(): PRAGMA table_info against the list of columns added
  since the first deploy; ALTER TABLE ADD COLUMN for each missing one (all
  nullable or defaulted). Append to that list whenever a column joins the CREATE.
  Test replays the original schema: health, ingest, stats, a legacy row reads
  back with the defaults.
- Trainer Handler._guarded(): any unexpected exception → 500 JSON naming it,
  never a dropped connection; /health keeps answering. Test drives readiness
  against an old-schema DB.
- The collector's training status proxy includes the trainer's error text.

Wiki: the incident and the schema rule (vision-review-outbox), what the message
means (bodytype-classifier-training), log. Deploy: the new collector migrates on
start; nothing manual.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-16 10:33:47 +02:00
julian 4a02e5fed3 docs(wiki): site device installation — the field runbook for what to know before the booth
Build & push images / images (push) Successful in 2m50s
Per device (Dingtian relay board, DT-008 readers, Hikvision G3H camera, radar,
K200L / Rongta / Cashino printers): factory address and login, the tool needed,
what the wizard configures by itself on assign vs what is done by hand on the
device, how to verify, and the traps already paid for (unauthenticated Dingtian
CGI + session_en brick, duplicate reader IP, reader settings lost on reset, sub
stream mandatory, Alarm Server after assign + Vehicle target filter, close the
camera web UI, radar idle level, "Test" only probes a printer). Bring list, the
10.0.10.x address plan beside each factory address, the on-site order of work,
and the gaps still unrecorded. Linked from appliance-provisioning and the K200L
page; log entries for the day.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-09 12:34:59 +02:00
julian 552d87d75b feat(devices): K200L printer driver — live cover/paper status from the J-Speed LAN board
The park-buzi printer is a K200L (Xprinter/ICS XP-K200L; its LAN board and USB
descriptor call it "POS-80"). Its board serves the Rongta's five-row status table
under /prt_status.htm — but the reply carries no HTTP status line or headers, which
node:http rejects, so the Rongta driver could never read it and the unit was filed
in July as "no status page → generic driver" (reachability only).

New `k200l` driver (printer-k200l.ts): prints through the generic ESC/POS device
(same bytes, TCP 9100 or usblp) and reads the page over a raw socket, tolerant of
both the headerless and a proper HTTP reply. Mapping mirrors the Rongta: board
unreachable → offline; page not understood → degraded, never ready; any fault →
degraded naming it; USB → reachability floor. The Rongta driver is untouched.
Tests replay the captured headerless page (devices suite 76). Live against the
lab unit: ready; with the cover open the board reports cover open, paper out,
off-line.

Wiki: new k200l-printer entity (names, network setup from factory
192.168.123.100, board quirks, status page, what it means for park-buzi — over
USB the app never saw cover/paper state at all), cross-links on the Rongta,
status-monitoring, USB-transport and WSL-networking pages (parking-net pinned to
eth1 while the LAN NIC is eth0), index.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-09 12:34:59 +02:00
julian 7e21cf057e chore(komodo): park-lab stack for the printer bench; wiki: cover-open bug bench result
Build & push images / images (push) Successful in 3m3s
park-lab re-added to resources.toml for the reproduction box: copied from park-2 then
corrected — the copy carried park-2's review outbox (collector URL, booth-2 id, booth-2's
token), which would have fed the training pool under a booth's identity; removed, the
outbox is off on the bench. Pinned to the booth's stage-2d9bb15, comments say what the
lab is for.

Wiki, printer-usb-transport: the failing printer identified (USB 1fc9:2016 "POS-80", NXP
controller, no brand in the descriptor); attached to WSL via usbipd and cover-cycled — no
disconnect, no re-enumeration, so the stale /dev/usb bind-mount hypothesis is falsified
for this unit; the next discriminator is the monitor's offline detail text on park-buzi
(EBUSY / open timeout / EIO). WSL caveat: Microsoft's kernel lacks CONFIG_USB_PRINTER.
fleet-deployment-komodo: park-lab row updated. Log: both entries of the day.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-09 10:19:18 +02:00
julian 4fd175e0e4 docs(wiki): subscription recurring billing + party ledger — design only
Subscription page: the one-window model can't express prepaid/postpaid, calendar or
anniversary anchoring, grace or an expiry notice; and renewal is OFF-BOOK today (a PUT
that appends no payment — the same hole closed for the first sale on 2026-06-20).
Designed: plan billing rule, per-day pricing so both anchors share one formula, open-
ended agreement, subscription_periods where each period is a ledger charge and a renewal
= paying the next period, one subscriptionAccess() gate function, expiry notice derived
not stored.

New decision page party-ledger: a counterparty sub-ledger for who-owes-whom across
modules — parties + signed charge / settlement / write_off events, balance derived never
stored, aging + statements + CSV; lands postpaid subscriptions, hotel guest-nights,
fleet washes on account, supplier/utility bills. Sub-ledger only: no bookkeeping, a
statement is not a fiscal invoice, parties per appliance. validation-sponsorship's
sponsor table marked superseded; open-questions #17; index.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-09 10:19:09 +02:00
julian 2d9bb15d4c fix(db): reset-db categorises the Car Wash tables and role_jobs — the drift guard refused every reset on a booth carrying them
Build & push images / images (push) Successful in 2m47s
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-07 14:37:49 +02:00
julian 4ff31557a8 feat(trainer): training from the collector UI — the trainer becomes a job service, the review page gains a Training section
Trainer: `parking-trainer serve` — a stdlib HTTP job API on the compose network (never
published): /health, /readiness, /versions, /versions/<v>/report, /jobs. One job at a
time; each job runs the CLI as a subprocess with its output captured, state + log
persisted under /out/jobs/ so a restart keeps history. `publish` takes its URL from
TRAINER_PUBLISH_URL. Dockerfile: CMD serve, EXPOSE 8091, healthcheck.

Collector: COLLECTOR_TRAINER_URL + /api/training/{status,jobs,jobs/:id,versions/:v/report}
— a reviewer-gated proxy that forwards a fixed set of paths and whitelisted knobs and
passes the trainer's status codes through (409 while a job runs; 503 unconfigured, 502
unreachable). /review gains the Training section: labels per class vs the minimum with
Train disabled until two classes clear it, mode / backbone / floor, the running job's
live log, the versions with Report / Evaluate / Publish (publish confirms), and the
reminder that pinning stays a git commit. Fixed on the way: an apostrophe in the page's
inline script broke the whole page — a test now parses the script.

Compose: `trainer` is a service (restart: unless-stopped, read-only data volume, its own
trainer-out volume), the `train` profile and TRAINER_OUT are gone; the Docker-socket
route was rejected (root on the host for a service booths upload to). Verified with both
images running together: a Train started through the proxy finished, version and report
came back, the page rendered.

Wiki: bodytype-classifier-training (loop, running it, operating notes superseded),
vision-review-outbox, fleet-deployment-komodo, log.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-07 14:34:13 +02:00
julian f7a262ac9a feat(trainer): phase-B body-type classifier — trainer job on the collector host + the classifier stage on the booth
Build & push images / images (push) Successful in 6m31s
apps/trainer (parking-trainer): inspect / train / evaluate / publish. Reads the wash
collector's SQLite + crops read-only off its volume; time split (validation = newest
slice); thin classes dropped; damped class weights; `features` mode (frozen ImageNet
backbone, on-disk feature cache, seconds to retrain) and `finetune` mode (light
augmentation). CPU-only torch from PyTorch's wheel index. ONNX export checked against
the torch model; NO model file below the validation floor (exit 3, report still written);
exit 2 = not enough labels. `evaluate` scores a shipped model on labels reviewed after
training + the unlabelled pile; `publish` PUTs a version folder to a Gitea generic package.
Light core deps; the `train` extra is heavy — CI syncs without it, torch tests skip.

apps/vision: BodyTypeClassifier (bodytype.onnx + sidecar = the preprocessing contract:
crop margin, input size, RGB 0-255, normalisation inside the graph) and
RefinedVehicleDetector over YOLOX — refines only `car` or a class the classifier trained
on, min-confidence, `detector_class` on the result; path set but no file = phase B off
without an error; a broken file is a health detail. models/bodytype.version (tracked,
empty) pins the published version the Dockerfile fetches at build (BuildKit secret;
a pin that cannot be fetched fails the build). Verified: a trainer model gives identical
probabilities inside the vision service; both images built and smoke-tested.

Delivery: parking-trainer image in build-images.yml, the `trainer` compose profile on the
collector stack (CPU, read-only data, TRAINER_OUT), commented TRAINER_OUT/PUBLISH_TOKEN in
the wash-collector stack, .dockerignore for both Python contexts, trainer deps synced in CI.

Wiki: bodytype-classifier-training rewritten as built (+ one fleet model not per site,
secrets/access, where the crops live), opencv-anpr-service §Phase B, vision-review-outbox,
vision-service-packaging, fleet-deployment-komodo, index, log.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-07 11:14:50 +02:00
julian f9cb973fe9 docs(wiki): collector live on park-2; secrets shape, DNS vs bind, token format, every-entry sampling, CI extra rule
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-07 10:01:39 +02:00
julian 1a0fe59488 docs(wiki): phase B training path and hardware decisions — recorded, not built
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-07 10:00:18 +02:00
julian dbbb051ebd feat(carwash): entry-stream sampling for the review outbox; park-2 wired to the collector
Build & push images / images (push) Successful in 4m22s
The wash stream is small; the entry camera photographs every car in exactly the view the
classifier is trained on. The booth can now queue entry vehicle reads as pure training
material — crop + the camera's class, no order, no operator, no category.

- Core announces every vehicle read (deviceEvents.emitVehicleRead from snapshot.ts); the
  Car Wash module listens, samples entry reads in-process (sampleEntry: exactly one in N)
  and queues them (enqueueEntry). CARWASH_REVIEW_ENTRY_SAMPLE=N; 1 = every entry (storage
  and bandwidth are not the limit — user); 0/unset = off. Forwarded by compose.
- Packages carry kind: "wash" | "entry". Collector: kind column, entry meta validated
  without the operator fields, review screen shows an entry sample as such, export has a
  kind column, operator agreement computed from wash items only. Setup line shows
  "1 in N entries sampled"; status carries entrySample.
- komodo: park-2's four review lines enabled (collector URL by Netbird DNS name, booth-2,
  the shared per-booth secret, every entry sampled) — the collector is up on the overlay.
- Tests on both sides. Wiki: vision-review-outbox (entry stream + the internet-feed
  assessment), log.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-07 09:38:55 +02:00
julian b485e9870b feat(collector): review collector skeleton — apps/collector, its own Komodo stack on the reviewer's host
CI / check (push) Failing after 40s
Build & push images / images (push) Failing after 32s
Build desktop / desktop (push) Successful in 5m24s
The far end of the Car Wash review outbox (wiki/concepts/vision-review-outbox.md): a small
Fastify + SQLite service in the monorepo (shares the payload contract and the class
vocabulary via @parking/shared), delivered to art-docker-station by its own stack so
nothing booth-side lands there and nothing of it on a booth.

- POST /ingest: bearer token per booth (constant-time), X-Booth-Id must match, multipart
  meta + JPEG (magic checked, 2 MB cap), meta validated against the contract, idempotent on
  the item id; crop stored at crops/<booth>/<item>.jpg on the volume + one items row.
- /review + /api/*: the reviewer's screen served by the process (Basic auth, one login):
  one pending crop at a time, operator's pick and camera's pick beside it, one button/key
  per vocabulary class + unusable + skip; stats per booth and per hashed operator
  (agree / disagree / unusable — disagree = the reviewer's class is outside the operator's
  category).
- GET /export/labels.csv: reviewed usable rows for training; formula-leading cells are
  neutralised (booth-supplied names). Crops stay on the volume for the trainer on the host.
- Booth payload now carries operatorCategory.classes so the comparison needs no site setup.
- Delivery: apps/collector/Dockerfile (monorepo context), docker-compose.collector.yml
  (bind to the overlay IP; commented `trainer` profile seam for the GPU), a third build
  step in build-images.yml, a `wash-collector` stack in komodo/resources.toml with one
  secret per booth referenced from both the collector's token list and the booth's own
  stack (park-2 lines templated, commented, DNS name for the URL).
- Tests: app.test.ts (ingest ok/dup/refusals, review + stats + export, config). Image
  built and smoke-tested locally (health, ingest, duplicate, auth, verdict, export).

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-07 08:26:22 +02:00
julian ec44547122 docs(wiki): close the session's loose threads — bay printer question, vocabulary-in-code decision, training note, guard family, OQ #16 settled
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-06 22:37:57 +02:00
julian e67f0ccef0 feat(carwash): review outbox, booth side — plate-blurred vehicle crop + the operator's choice, queued for a trusted remote reviewer
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
2026-09-06 22:33:43 +02:00
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
julian 55d6242c7d feat(permissions): per-desk till guards, jobs in the role composer, permission-scoped live feed; role reassignment applies without re-login
CI / check (push) Successful in 46s
Build & push images / images (push) Successful in 2m58s
Build desktop / desktop (push) Successful in 4m53s
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
2026-09-05 14:45:48 +02:00
julian a9ccf9e20c feat(carwash): Car Wash v1 + per-till shifts + site-level pay-at + till access by module permission
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
2026-09-05 13:23:09 +02:00
julian ea8fe22969 docs(wiki): USB printer cover-open field bug writeup; add art-docker-station lab box
Build desktop / desktop (push) Successful in 5m14s
Build & push images / images (push) Successful in 3m1s
CI / check (push) Successful in 43s
Printer investigation (park-buzi): cover-open on the USB thermal
printer wedges its status offline/faulty, surviving a full reboot,
recoverable only via `docker restart server`. Traced sendRawUsb/
probeUsb end-to-end — no persistent handle in the app layer, so the
leading theory is the container's /dev/usb directory bind-mount
retaining a stale view across the printer's physical re-enumeration.
Not yet confirmed on hardware; documented with repro/confirmation
commands and ranked candidate fixes.

Also registers a new lab bench box, "art-docker-station", as a Komodo
Stack (dev tier, same shape as park-lab, its own isolated secret refs).

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-08-30 18:11:34 +02:00
julian 2910672b5a fix(backup): persist last-success/error status; wall-clock-based schedule
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
2026-08-30 18:11:23 +02:00
julian 19dff97c74 fix(web): permission-degrade the app shell for merchant-only users
Build desktop / desktop (push) Successful in 4m51s
Build & push images / images (push) Successful in 3m8s
CI / check (push) Successful in 52s
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
2026-07-13 20:12:43 +02:00
julian 28bd838696 docs(wiki): merchant validations settled + as-built; scan input decided (camera paths postponed)
Build desktop / desktop (push) Successful in 5m5s
CI / check (push) Successful in 47s
Build & push images / images (push) Successful in 2m59s
validation-discounts: driving cases → the settled validation-only model (all
money/paper at the booth) → setup UX/storage/RBAC → full as-built record.
DECIDED: merchant stations scan with a USB/HID barcode scanner on the
web/desktop app (hand-keying + Luhn as fallback); POSTPONED with analysis:
web getUserMedia scanning (secure-context TLS prerequisite on the LAN +
Code128-via-camera weakness → QR-on-ticket first) and a Tauri v2 Android
merchant app (native ML Kit scanning; Android build/sideload overhead +
configurable-server-URL prerequisite). Also: wsl-dev-networking gains the
mirrored-mode gotcha where a Windows-side listener makes a port EADDRINUSE
inside WSL while invisible to ss — Vite auto-increments and tauri dev's fixed
devUrl waits on the wrong port.

Claude-Session: https://claude.ai/code/session_01YYkpEsLmoQPaize5ec3oUm
2026-07-13 19:50:09 +02:00
julian 22544ecf63 docs(wiki): log-storm hardening + reset drift guard (2026-07-07 incident)
Build desktop / desktop (push) Successful in 4m37s
CI / check (push) Successful in 42s
Build & push images / images (push) Successful in 2m51s
button-light-indicator: failure backoff + rate-limited logging rationale;
app-logs: storm coalescing invariant + --diagnostics wipe; local-dev-workflow
and appliance-provisioning §7d: new reset flag table + drift guard; log entry
tying all three layers to the ENETUNREACH incident.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-10 08:29:59 +02:00
julian 5287be5278 docs(wiki): catch-up sweep — five pages lagging the log
Build & push images / images (push) Successful in 2m52s
CI / check (push) Successful in 43s
rongta-printer still named the cashino driver id (→ escpos + migration
0023 note); tariff-time-tiers listed the composer price preview as
deferred (→ delivered by the lab fee breakdown); tariff.md lab section
gained the breakdown + composer increment-guard paragraph; i18n.md now
records the "25 Qer 14:30" date standard + never-toLocaleString-for-
dates rule; fleet-deployment-komodo gained the park-lab stack + tier
table (the park-lab addition had also slipped the log — both fixed).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-07 13:08:55 +02:00
julian cd3b534e51 feat(setup): USB printer discovery — pick a real /dev/usb device
Build desktop / desktop (push) Successful in 4m21s
CI / check (push) Successful in 50s
Build & push images / images (push) Successful in 2m54s
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
2026-07-07 11:35:25 +02:00
julian 011fe5a4c4 fix(devices): USB truncation mode 2 — close() kills the in-flight usblp URB
Build desktop / desktop (push) Successful in 4m16s
CI / check (push) Successful in 43s
Build & push images / images (push) Successful in 2m51s
The chunked-write fix (81bc2e3) still truncated on hardware: the lab
test slip stopped mid-sentence with no feed and no cut (text hidden
until the feed button). Verified against drivers/usb/class/usblp.c:

- write() returns at URB SUBMISSION, not completion;
- only ONE write URB is in flight (the next write EAGAINs until it
  completes);
- usblp_release() — our close() — KILLS in-flight URBs.

The printer drains bulk data at PRINT speed (tiny internal buffer on
these clones), so closing right after the last accepted write cancels
the still-transferring tail — exactly where the feed + GS V cut bytes
live. Kernel-accepted ≠ printer-received.

Fix: the one-URB rule makes acceptance of write N a completion
certificate for write N−1. writeAllUsb now writes the payload's FINAL
BYTE alone — its acceptance proves everything before it is physically
in the printer — then drains 300 ms for that single packet before the
caller closes. New test pins the final-byte-alone chunking; wiki
printer-usb-transport.md carries the kernel-level account.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-07 10:50:47 +02:00
julian 81bc2e357c fix(devices): USB printing dropped the job tail — chunked write loop
Field bug (ICS XP-K200L over USB): text printed, barcode + cut missing;
same bytes over TCP fine. sendRawUsb did ONE write() on an O_NONBLOCK
usblp fd and never checked bytesWritten — the kernel accepts only what
fits the printer's ~8 KB USB buffer and returns a short write, so the
tail of any job bigger than one buffer (the barcode mid-payload, the cut
at the end) was silently discarded. The regular-file test stand-in can't
short-write, which is why tests never caught it.

writeAllUsb now pushes 4 KB chunks until every byte is accepted,
continues after partial writes, retries EAGAIN/zero-byte with a short
pause, and fails at the deadline with an (N/M bytes) diagnostic. Driven
by fake-handle tests (short writes, EAGAIN interleave, wedged-printer
timeout, non-EAGAIN passthrough).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-06 12:36:14 +02:00
julian 7ef332999e feat(reports): occupancy curve, hour×dow heatmap, stay histogram, fraud KPIs
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
2026-07-06 12:35:54 +02:00
julian 14638c2e13 docs(wiki): industry survey of parking tariff systems + session log
Build desktop / desktop (push) Successful in 4m14s
CI / check (push) Successful in 43s
Build & push images / images (push) Successful in 2m49s
New reference page tariff-industry-survey.md (2026-07 web research):
field taxonomy — per-started-increment hourly (per-minute tried and
rolled back in practice), degressive ladders, day caps, up-to matrices,
day tickets, evening/overnight packages, event rates, early bird
(entry-time-conditioned), day/night + weekend/holiday/seasonal windows,
category pricing, contracts, merchant validations (amount/percent/
time-credit/re-rate), SFpark-style dynamic pricing. Coverage map: our
engine expresses everything a staffed single lot advertises; real gaps =
early bird (the pick-table-by-entry-time future design, same mechanism
as weekend menus) and validation overlays; anti-features = per-minute
billing + dynamic pricing. Indexed + logged.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-05 16:37:53 +02:00
julian 4f902d869e feat(web): published-versions sidebar on the composer page
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
2026-07-05 16:37:53 +02:00
julian c5ed3f1308 feat(drawer): drawer hub — balance now, this-shift figure, daily activity, shift history; busy spinners
Build desktop / desktop (push) Successful in 4m12s
Build & push images / images (push) Successful in 2m53s
CI / check (push) Successful in 41s
/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
2026-07-05 15:56:30 +02:00
julian fd9885e9ec feat(tariff-lab): DB-backed draft tariffs + named published versions
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
2026-07-05 14:31:42 +02:00
julian d9e6c13831 feat(tariff): whole-window package pricing mode (packageMinor)
A windowed card can now charge ONE total for any presence in its window —
the real night rate ("20:00–07:00 = 400, leave earlier and it's still
400"), which the per-increment flatMinor could not express (park-buzi's
"night 400" card billed 400/HOUR). Engine charges once per contiguous run
of increments the card wins, tracked across rolling-day segments so a
night crossing the 24h boundary charges once; out-of-window increments
price by the base card as usual.

Operator decisions (2026-07-05): per-occurrence repeat (two nights = two
charges), any-touch-pays-full, windowed cards only (a base "price per
day" is a 1-row up-to table). Validator: mutually exclusive with
flat/blocks/steps, no per-card cap, forbidden on the defaultCard.
flatMinor docs clarified as PER INCREMENT. 6 new engine tests.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-05 14:31:25 +02:00
julian c21babf293 feat(logging): ~2-month container rotation, ISO timestamps, level names
Build & push images / images (push) Successful in 2m54s
CI / check (push) Successful in 41s
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
2026-07-04 19:47:53 +02:00
julian 35e593ab63 docs(wiki): DT-008 phantom-scan diagnosis + backfill bypass/relay-test concept pages
Two independent wiki updates bundled (all docs):

1. dingtian-dt008-reader.md: phantom optical decodes on the park-buzi EXIT
   reader (empty pre-opening site, low-sun afternoons). Chain of evidence:
   READ log lines carry the reader's own serial (H05MA5B0) → physical device,
   not a network source; snapshot shows nobody present; code shapes are the
   giveaway (6-digit numerics = checksum-less Interleaved 2-of-5, lone "C" =
   Code39/Codabar artifact) → 1D engine decoding sun-made stripe patterns
   (striped arm, fence shadows, glare). No fraud exposure (11-digit Luhn ids
   can't match); noise only. Fix on the entity page: vendor-tool symbology cut
   to QR+Code128 + min decode length, BOTH readers; config lives ON the device
   → re-apply after any factory reset/swap. Deliberately NOT filtering
   impossible codes server-side — probe recording is the anomaly path's job.

2. Backfilled two shipped-but-undocumented features (six code files already
   linked the first page as if it existed):
   - concepts/entry-presence-bypass.md — admin drops a FAULTY presence signal
     (granular radar/camera by decision, not a master switch); every flip is a
     signed config_change; persists till off; tickets stamped presenceBypassed;
     radar-bypass cooldown tradeoff; "the admin is not the adversary, but
     trusted never means invisible".
   - concepts/setup-relay-test.md — admin-only commissioning pulse, signed
     barrier_open_command BEFORE the fire so a test open never reads as the
     out-of-band-open fraud signal; saved controllers/declared relays only;
     radarAlert lamps excluded; pulseOpen only.
   Cross-linked from operator-issued-entry.md, cataloged in index.md, logged.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-04 19:00:49 +02:00
julian b4f1418858 fix(entry): enforce the camera press-gate + duplicate-ticket defenses
Field report (park-buzi): a BLINKING entry button still printed — the lamp
encoded blink-vs-solid (radar-only vs radar+camera) but #suppressReason only
checked the radar, so a radar false-positive (rain, pedestrian) minted a real
signed ticket. Three layered fixes:

1. CAMERA gate on the physical press: with an entry camera configured, a press
   is live only in the lamp's SOLID state (LaneStatus.entry busy, mirrored into
   EntryFlow via onLaneStatus). Suppress-only — the camera stays advisory (never
   opens, never traps). Camera-less sites keep the radar-only gate; a faulty
   camera is dropped via the existing bypassPresenceCamera admin toggle.

2. Cooldown as a REAL backstop behind presence: the presence branch returned
   early, so entryCooldownSec was dead wherever a loop was wired. Now it bounds
   the stationary-car double-ticket (a motion radar drops a motionless car →
   spurious loop-clear re-arms one-car-one-ticket → same car reprints).

3. Post-hoc duplicate-plate anomaly (entry-side twin of plateSwapSuspected):
   when entry ANPR recognizes a plate already OPEN under another session entered
   within ENTRY_DUP_PLATE_WINDOW_MIN (default 15 min), sign ONE
   entry.duplicatePlate anomaly naming both tickets for the operator to void.
   ANPR stays non-blocking (rides the post-open snapshot as before).

REJECTED: camera-vetoed re-arm (defer re-arm until the lane flips free). The
camera has no leave events — "free" is a ~30s silence timeout that never lapses
inside a queue, so every queued car after the first would be suppressed until
an operator intervened. Blocking legit entry at peak beats nothing; the proper
preventive fix is a pass-through sensor (passedInput) — recorded as open in
wiki/concepts/entry-double-press.md.

Also: setup.relayTest reason was missing from both web catalogs (parity is only
enforced sq<->en, so the build passed) — added.

Tests: entry-press-gate.test.ts (blink suppresses / solid prints / camera-less
unaffected / bypass honored / cooldown catches the dropout re-press / residual
risk documented / still-present re-press stays suppressed) +
entry-duplicate-plate.test.ts (flags open dup, ignores closed/stale/self/other
plates). Suite 258 green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-04 18:41:06 +02:00
julian f486dcbbfc docs(wiki): vision-service hardening backlog + boot-migration data-seed note
Build desktop / desktop (push) Successful in 4m32s
Build & push images / images (push) Successful in 2m58s
CI / check (push) Successful in 41s
Two unrelated leftover wiki edits from earlier sessions:
- NEW concepts/vision-service-hardening.md: the prioritised to-do list from the
  2026-07-02 code + security reviews of apps/vision/ (DoS gaps, unauthenticated/
  operator-writable model weights, 0.0.0.0 default bind). Cross-linked from
  opencv-anpr-service.md ("consult before touching this service").
- container-deployment.md: note that a boot-time migration can be a DATA SEED
  (e.g. an RBAC permission granted to the operator role via INSERT OR IGNORE),
  and that a built-in-role grant does not auto-apply to a custom role.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-04 13:41:32 +02:00
julian c142166972 docs(wiki): ATECC608 is upcoming — retag ledger signing to the on-host reality
No secure element is on-site: event signing runs on the software HMAC
(EVENT_SIGNING_KEY, an env var on the host disk), so the ledger is
tamper-EVIDENT but forgeable by anyone who owns the host. Several pages
overstated it as present-tense "ATECC608-signed / unforgeable"; correct them.

- NEW concepts/hardware-signer-options.md: four options for a non-extractable
  signing key (USB HSM / YubiKey / reuse the TPM / plain-dongle trap) + the
  recommendation (TPM interim → USB-HSM target; ATECC608 stays for the embedded
  ESP32, wrong part for a PC host).
- entities/atecc608.md: UPCOMING-not-present status banner + PC-vs-embedded.
- disk-os-hardening.md: fix the live-USB row (BIOS boot-order password is
  load-bearing, not Secure Boot — a signed live USB runs); add a physical-tamper
  chain (Dell 7070 CMOS-reset → live-USB → PCR-7 same-signer unseal) + accepted
  risks (that unseal, unsigned-initramfs evil-maid, operator-USB read TODO).
- open-questions #6 reframed; standing-decisions / overview / threat-model /
  index de-overstated; log query entry.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-04 13:41:11 +02:00
julian 33c4ea1e91 feat(entry): operator-issued entry + exit plate-swap reconciliation
Build desktop / desktop (push) Successful in 4m29s
Build & push images / images (push) Successful in 2m51s
CI / check (push) Successful in 37s
Two halves of one anti-fraud design.

(A) Operator-issued entry — when the physical entry button is broken, an
operator can issue an entry ticket so a real car isn't blocked out of the lot.
This hands the operator-adversary a mint, so it is:
  - PRESENCE-GATED like the physical button: a real car must be present (radar/
    loop AND camera busy). Enforced BOTH sides — the server re-checks current
    presence so a direct POST can't bypass a disabled button; no presence loop
    => feature unavailable; a no-presence attempt signs an anomaly.
  - FLAGGED: vehicle_entry source=manual + operatorInitiated + operator, PLUS a
    companion entry.operatorIssued anomaly (the adversary path always leaves a
    red-flag row).
  - capacity-OVERRIDE allowed but stamped lotFull (a broken button mustn't trap
    a legit car).
  New session:create permission (migration 0019 -> operator role, admin-
  revocable), POST /api/entry/issue (open-shift gated), EntryFlow.
  issueForOperator; the fraud-critical print->sign->open->snapshot sequence is
  factored into one shared #issueTicket (button + operator). UI: the entry
  BarrierLight becomes a clickable issue-control when presence+permission+shift
  meet (confirm -> issue).

(B) Exit plate-swap reconciliation — defends the ticket-swap fraud the mint
enables (paid car let out on a fresh $0 ticket, original ticket lingers
"inside", occupancy drifts up by phantom cars). The plate is the invariant:
ExitFlow.#reconcilePlateAtExit compares the exiting plate against all OPEN
sessions' entry plates, EXACT + HIGH-CONFIDENCE only (>=0.85; a fuzzy read never
gates — ANPR is advisory). On a match under a DIFFERENT ticket:
  - BOOTH path: returns swap_suspected + signs exit.plateSwapSuspected; the
    pay/exit modal shows a red warning + "Override & release" (override signs an
    attributed exit.plateSwapOverride). Flag+override, never a silent hard block
    (exit fails-open; a plate is never the sole gate).
  - READER path (no operator): log-only anomaly + fail-open.
  Extended BoothExitResult + /api/exit (override); boothExit client returns a
  structured swap result.

Verified: full monorepo build/lint/test green (229 server tests incl. 4 new:
hold-on-swap, override-releases-with-attribution, low-confidence-no-warning,
own-plate-no-warning). New wiki: operator-issued-entry.md +
plate-reconciliation.md; cross-linked from entry-exit-points, capacity-
occupancy, index. Preserves "a plate never OPENS a barrier alone — and now never
TRAPS a car alone either."

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-01 12:17:52 +02:00
julian 114a32e6f2 feat(drawer): operator records cash movements, admin reviews after (own /drawer route)
Rework drawer cash movements from synchronous admin-authorization-at-creation
(operator typed an admin's password inline for every receipt/disbursement) to
operator-records-freely -> admin-reviews-after.

- New `drawer` resource: drawer:create (operator records; admin-revocable per
  role) + drawer:review (admin authorizes/denies). Migration 0018 grants the
  default operator role drawer:create; admin gets all in code.
- New signed `cash_review` ledger event { refId, decision, reviewedBy, note? }.
  A DENIAL is a FLAG, not a reversal: it never appends reversing cash and never
  touches the drawer balance (the correction is settled outside the app). This
  is what keeps a late review from leaking into the next operator's inherited
  drawer — a denial that lands after the reviewed shift closed moves no cash.
  Regression test: op1 disburses -> closes -> op2 inherits -> admin denies ->
  op2 drawer unchanged.
- Move the feature OFF the polluted /shifts route to a top-level /drawer
  (operator: record + own; admin: review queue + all). routes/drawer.ts lifted
  from routes/shift.ts (retired the authorizer-password gate; kept shift:cash
  for its other job = admin-sees-all-shifts). New DrawerManager.tsx.

Display fixes bundled:
- Render cash_review in the event-detail modal (decision / reviewed-by / note /
  movement ref) — previously showed nothing.
- Relabel the shift drawer figures for clarity: Daily takings / Receipts /
  Disbursements (was Cash payments / Cash added / Cash removed).
- Hide the Card figure everywhere when CARD_PAYMENTS_ENABLED is false (no POS
  on-site), matching the card-tender gate.

shared/db/server/web all typecheck; 225 server tests pass (incl. the drawer
review + cross-shift-leak regression); web build + i18n parity green. Verified
end-to-end via Playwright. Recorded in wiki/concepts/shift.md.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-01 11:17:20 +02:00
julian 018328a877 feat(booth): disable card tender until a P2PE POS is on-site (cash-only)
No card processor / POS terminal on any site yet. Offering "Card" would let an
operator record a card payment that never cleared a terminal, corrupting the
till reconciliation — a fraud/error surface on an operator-adversary system.

Add apps/web/src/lib/features.ts → CARD_PAYMENTS_ENABLED=false, gating both
tender pickers (BoothPayModal, SubscriptionManager). With card off there's
nothing to choose, so the tender row is suppressed and payment defaults to
cash. UI-only gate: the Tender type, payment events, shift accounting, and
reports still understand `card`, so historical card events and a future
re-enable stay coherent.

Verified via Playwright: an unpaid-ticket modal shows Total + "Pay + open
barrier" with no tender/cash/card row.

Wiki: new concepts/card-payments.md records the current cash-only state, the
PCI-scope-out-of-app constraint, the future-POS device requirements, and the
re-enable path (flip the flag once a bank-certified P2PE terminal is
provisioned). Linked from index, parking-session, open-questions #3.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-01 09:57:03 +02:00
julian 266e9b0027 docs(wiki): record session findings — snapshot fix, booth rework, db reset
Build desktop / desktop (push) Successful in 4m34s
Build & push images / images (push) Successful in 3m1s
CI / check (push) Successful in 40s
- entry-exit-points.md: the snapshot content-type bug + serve-side cleanType
  fix (Hikvision image/jpeg; charset="UTF-8" broke every legacy render).
- booth-exit-flow.md: the Active-Sessions/modal rework — inline barrier button
  removed -> modal; closed-within-grace view; live grace countdown; actual paid
  amount; read-only snapshot review in the closed-session view.
- local-dev-workflow.md: the gated `pnpm db:reset` training tool + flag table +
  the booth (docker exec, no pnpm) note.
- appliance-provisioning.md: new §7d — reset on the booth via docker exec into
  the server container (script ships in the deploy bundle; DATABASE_URL=
  /data/parking.sqlite), ledger-truncation warning + the two safety gates.
- index.md catalog line; log.md entries.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-30 17:58:43 +02:00
julian 9c6741a485 docs(wiki): record backup deploy gotchas (compose allowlist + host mount)
CI / check (push) Successful in 40s
Two lessons from the first park-buzi staging deploy, both in backup-recovery.md:
- A new server env var (BACKUP_KEY) must be added to docker-compose.yml's
  server.environment: allowlist, not just the Komodo secret/Stack env — otherwise
  the container never receives it (inspect shows it absent, not empty).
- The backup target must be a host path bind-mounted into the container; a desktop-
  automounted USB (/run/media/...) is invisible inside the container, so Test target
  reports 'does not exist'. Destinations are admin-provisioned (fstab + compose bind-
  mount), not operator-pluggable — partly a threat-model feature. Acknowledged as a
  flexibility limitation; USB-automount-to-container flow deferred.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-29 17:38:24 +02:00
julian 84f00db48b feat(backup): admin-tunable retention + BACKUP_KEY as a Komodo secret
Build desktop / desktop (push) Successful in 4m17s
Build & push images / images (push) Failing after 39s
CI / check (push) Successful in 39s
Retention (keep-last / keep-daily-days) is operational policy the on-site admin
should tune, not a server env var requiring a redeploy -- same reasoning that moved
the target directory to the UI.

- Migration 0017: site_config.backup_keep_last + backup_keep_daily_days (nullable;
  null = code default 7 / 30 per field).
- BackupService reads retention fresh each run; status() exposes keepLast +
  keepDailyDays. DEFAULT_BACKUP_RETENTION is now a pure code default (env reads gone).
- PUT /api/backup/config accepts keepLast / keepDailyDays (non-negative int, or null
  to reset to default; 400 on negative).
- UI: two retention fields on the Backup config card; one Save covers target +
  retention. i18n sq + en.

BACKUP_KEY wired into Komodo:
- komodo/resources.toml: BACKUP_KEY=[[park_buzi_backup_key]] (per-booth secret,
  alongside JWT / signing keys).
- komodo/.env.komodo.example: documents it as the ONLY backup env var -- escrow it
  offsite alongside EVENT_SIGNING_KEY (recovery needs both); target + retention are
  admin-chosen in the UI / DB, not env. Server .env.example trimmed to just BACKUP_KEY.

Also carries the small in-progress setup-intro i18n copy trim.

Tests: 218 server tests green, incl. retention persist / reset-to-default / reject-
negative and the updated status shape. Migration applies cleanly (needed a
statement-breakpoint between the two ALTERs). Wiki backup-recovery updated.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-29 12:52:18 +02:00
julian d5e41500a8 feat(backup): admin UI with admin-chosen target directory
Build desktop / desktop (push) Successful in 4m42s
Build & push images / images (push) Successful in 2m53s
CI / check (push) Successful in 39s
The backup destination is now chosen by the on-site admin in the UI (Setup ->
Backup), not a server env var. An env-pinned target defeats the purpose: the admin
can't point backups at a freshly-plugged USB or a NAS mount without editing .env
and restarting. The encryption key stays a server secret.

Target storage:
- New site_config.backup_target_dir (migration 0016, nullable; null = not
  configured). BackupService reads it fresh each run, so a UI change takes effect
  with no restart. Only BACKUP_KEY stays env -- a key must never live in the DB it
  backs up.

Routes:
- PUT /api/backup/config  -- set/clear the target (backup:update; upserts id=1).
- POST /api/backup/test   -- probe a candidate path server-side (exists / is a
  directory / writable) so the admin gets feedback before relying on it.
- status() now exposes targetDir + keyPresent, so the UI distinguishes
  'no target set' from 'BACKUP_KEY missing'.

UI (apps/web/src/BackupSettings.tsx):
- A Setup -> Backup tab (gated backup:read): an editable target-path field with a
  Test-target probe (localized ok/missing/not-a-dir/not-writable), Save, the status
  panel (config state, last-run size/pruned/error, a distinct amber missing-key
  warning), a Back up now button, and the restore-is-out-of-band note. Full i18n
  (sq + en); nav.backup.
- API client: fetchBackupStatus / setBackupTarget / testBackupTarget / runBackup.

Also includes a small in-progress copy trim to the setup-intro i18n strings.

Verified live with Playwright: typed a path -> Test reported writable -> Save
persisted it -> status reflected it and showed the key-missing warning. Whole
monorepo build/lint/test green. Wiki backup-recovery + open-question #5 updated.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-29 12:21:26 +02:00
julian 0c218179c4 feat(backup): encrypted on-site DB backup engine + local target
The SQLite DB is the signed append-only ledger, so a disk failure / stolen or
destroyed PC means total revenue-history loss (open-question #5). This is the first
slice of the backup-recovery design: the engine + a local/mounted target + a daily
timer + a manual route.

Engine (apps/server/src/backup.ts):
- Consistent online copy of the live WAL DB via better-sqlite3's native .backup()
  (not a raw file copy, which can capture a torn WAL) — the restored copy is a
  byte-identical, queryable DB.
- AES-256-GCM with a scrypt-derived key from BACKUP_KEY; self-describing header
  (magic|version|salt|iv|...|authTag) so a restore tool needs only the key + file.
  Zero new dependencies (Node crypto).
- The plaintext intermediate is kept in scratch (not the removable/network target)
  and wiped in a finally, success or fail.
- Retention: keep-last-N + one-per-day within N days.

Wiring:
- BackupService (env config, single in-flight guard, last-success/last-error).
- routes/backup.ts: GET /api/backup/status (backup:read), POST /api/backup/run
  (backup:create), 409 when unconfigured. No restore route — restore is an
  out-of-band runbook action on a fresh appliance, not a console call.
- New  permission resource in @parking/shared.
- server.ts: an unref'd daily timer, a no-op until BACKUP_TARGET_DIR + BACKUP_KEY
  are set, deliberately not run at startup (a just-power-cut booth shouldn't write
  to a possibly-unmounted disk).
- openRawDb() added to @parking/db/testing (open a file without migrating, for
  restore-verification tests).

BACKUP_KEY is deliberately SEPARATE from EVENT_SIGNING_KEY (independent rotation;
backups travel, the signing key shouldn't). SMB/NFS work as mount paths; SFTP +
admin UI + restore runbook are deferred slices. Tests: round-trip byte-identical,
GCM tamper/wrong-key fail, short-key rejected, scratch cleaned, route auth/RBAC +
409. build/lint/test green (212 server tests). Wiki + open-question #5 updated.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-29 11:59:45 +02:00
julian 9e442586af docs(wiki): settle on-site encrypted backup + disaster-recovery design
New concept page backup-recovery.md resolving the design half of open-question #5.
Driving scenario: a stolen/destroyed PC whose LUKS+TPM disk is unrecoverable by
design — recovery stands up a NEW PC, restores a backup, and keeps signing the
SAME chain.

Settled: admin-driven encrypted full-DB backup (SQLite online-backup/VACUUM INTO,
snapshots included) to local/USB, SMB/NFS, or SFTP targets; manual button + an
in-process daily timer; keep-last-N + dailies retention; restore is admin-only /
out-of-band (operator-adversary surface). A restored copy must still verifyChain.

Key custody (the load-bearing decision, bears on #6): three independent keys —
EVENT_SIGNING_KEY kept an extractable, escrowed software key DECOUPLED from the
TPM so the ledger survives total hardware loss (the conscious trade: a TPM-sealed
signing key would be unforgeable but permanently unverifiable after the machine
dies); a NEW dedicated park_buzi_backup_key in Komodo for backup encryption,
separate from the signing key; the LUKS/TPM disk key, appliance-only and
deliberately non-recoverable. Keys are never inside the backup they unlock.

Updated open-questions #5 (design SETTLED) + #10 note; disk-os-hardening deploy
runbook (why the signing key is not sealed + park_buzi_backup_key); index catalog
+ concept count. Design only — not yet built.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-29 11:43:23 +02:00
julian 11567a417f docs(wiki): catch concept pages up to the booth-UX/shift/font-scale work
Bring three queryable pages current with the booth-UX commit (cce99aa) whose
breadth hadn't propagated:
- booth-console: Active Sessions as a real table, dropped status column/filter,
  inline live-feed rows, removed TARGE via-badge + redundant Direction filter,
  plate now searchable + backfilled via plate-recognized WS push, per-user font scale.
- shift: Z-report display simplified (shitje dropped, opening cash added) while
  the signed payload is untouched.
- i18n: users.font_scale recorded alongside language/theme as the matching
  per-user server-stored pref (migration 0014).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-29 11:43:12 +02:00
julian f6e35bbebf fix(reader): correct the QR reader's identity — Dingtian DT-008, not "GEE"
Build desktop / desktop (push) Successful in 4m16s
Build & push images / images (push) Successful in 2m46s
CI / check (push) Successful in 38s
An early wrong assumption named the QR/RFID access reader "GEE" /
"GEE/Fondvision" / "GEE-QR-ER80" (and summarized a raw GEE PDF as its
datasheet). There is no GEE device — it's the Dingtian DT-008
(dingtian-tech.com/en_us/qr_code_reader.html), the same vendor as the relay
board, which is why it integrates the identical HTTP-GET-push way.

Code:
- Driver symbol geeQrReaderDriver → dingtianQrReaderDriver; label →
  "Dingtian DT-008 QR/RFID reader (HTTP push)"; comments/description rewritten
  to the real DT-008 facts (Wiegand 26/34, TCP/IP, USB, RS485 — not RS-232;
  QR/barcode + ID/IC/NFC — not DataMatrix/1D).
- Persisted driverId "gee-qr-reader" → "dingtian-qr-reader" (the registry
  lookup key + the row created on assign in qr-reader.ts).
- Migration 0015 rewrites existing devices.driver_id rows so configured readers
  keep resolving (applied to the dev DB — 2 rows; the booth applies it on boot).
  Behaviour is unchanged: naming + the persisted id only.

Wiki + memory:
- Renamed entities/gee-qr-er80.md → dingtian-dt008-reader.md and
  sources/gee-qr-er80.md → dingtian-dt008.md; rewrote both to the real DT-008
  product-page specs while KEEPING all the verified-on-hardware protocol facts
  (cjihao serial, .jsp path, Connection: close). Fixed every cross-reference +
  "GEE" mention in 6 other pages. Memory gee-reader-serial-binding →
  dingtian-reader-serial-binding. The only surviving "GEE" mentions are
  deliberate naming-correction notes, the raw PDF filename, and the
  append-only log history.

Full workspace build/lint/test green; dev DB readers verified resolving to the
registered dingtian-qr-reader driver.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-28 17:39:15 +02:00
julian 96acd6b662 feat(snapshot): re-encode captures + disk-pressure retention
Build desktop / desktop (push) Successful in 4m13s
Build & push images / images (push) Successful in 2m56s
CI / check (push) Successful in 38s
Camera snapshots were stored RAW — the camera's full-res JPEG straight into
the BLOB, no resize/recompress. Measured on the dev DB: 300 snapshots = 81.7 MB
= ~72% of the 114 MB SQLite file (the big ones 2688×1520 / ~600 KB, Hikvision
main stream). They dominated the appliance's single backed-up DB file.

Re-encode on capture (snapshot.ts):
- Downscale each frame to SNAPSHOT_MAX_EDGE (1280px long edge) + recompress at
  SNAPSHOT_JPEG_QUALITY (80) via sharp (libvips, Apache-2.0) before storage —
  ~6-10× smaller (verified 2688×1520 → 1280×724, ~8×), plate still readable,
  clean image/jpeg (drops the camera's charset cruft). STORAGE-ONLY: recognition
  keeps the ORIGINAL full-res bytes (downscaling hurts OCR). Fail-soft — a
  re-encode error stores the original, never drops the snapshot or blocks the
  (already-open) path. sharp lives in apps/server (owns the capture path), where
  bcrypt already establishes the native-dep pattern.

Disk-pressure retention (snapshot-retention.ts) — a SAFETY VALVE, not the daily
mechanism (the re-encode does that). Daily check reads the DB filesystem used%
(statfs on db.$client.name); no-op unless ≥ SNAPSHOT_DISK_HIGH_PCT (70). Over the
mark: delete the OLDEST until an estimated SNAPSHOT_DISK_FREE_TARGET_PCT (10%) of
disk is freed — never below SNAPSHOT_MIN_KEEP (500) — then VACUUM once to return
space to the OS. A DELETE only frees SQLite pages (disk doesn't drop until VACUUM),
so the loop is driven by estimated freed bytes (SUM(length(bytes))), not a live
disk re-read; the prune owns the DB-locking VACUUM, run daily off-peak. diskUsage
is injectable for tests. None of this touches the signed ledger — snapshots are
unsigned/advisory, referenced only by id.

Tests: encodeForStorage (downscale / clean-type / no-enlarge / fail-soft) +
pruneSnapshots (no-op below mark / delete-oldest-to-target + VACUUM / MIN_KEEP
floor / skip-VACUUM-when-empty). All four snapshot env knobs documented in the
komodo env reference. Full workspace build/lint/test green; the prune smoke-verified
on a scratch DB copy (file shrank after VACUUM).

Existing ~81.7 MB of raw snapshots are unchanged (a one-off re-encode backfill is
a separate optional follow-up). Updated entry-exit-points + technology-stack wiki.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-28 17:15:15 +02:00
julian 38481f105f feat(booth): blink the Entry/Exit lights on radar presence (mirror relay 3)
Build desktop / desktop (push) Successful in 4m14s
Build & push images / images (push) Successful in 2m42s
CI / check (push) Successful in 37s
The on-screen Hyrje/Dalje barrier lights were 2-state (green=free / red=busy)
off the camera lane-status only — they couldn't show the radar-only "detected,
not yet confirmed" state that makes the physical button lamp (relay 3) blink.
Now they mirror the lamp's 3-state rule per lane:
  radar present + camera not busy → BLINK green↔red (~1 Hz)
  camera busy                     → SOLID red
  otherwise                       → SOLID green

End-to-end:
- LanePresence (lane-presence.ts): subscribes to deviceEvents.onInput, resolves
  each presence edge to its lane via the new direction-agnostic presenceLaneOf()
  (device-resolve.ts) — entry AND exit, unlike the entry-gated relayForPresence
  the one-car-one-ticket gate uses — and emits a lane-presence {entry,exit} bus
  event on change. Wired in server.ts (start + onClose).
- WS forwards it (hello snapshot + push) into live-store.radar.
- BarrierLight (BoothScreen.tsx) is now 3-state; blinks via the .lane-blink
  keyframe (index.css), which holds solid-red under prefers-reduced-motion.

Same input + same rule as the lamp, so the screen and the post never disagree.

A new test (lane-presence.test.ts) caught a real bug: the first cut reused
relayForPresence, so the EXIT lane never resolved (it's entry-gated) and never
blinked — presenceLaneOf fixes it. Covers entry/exit independence, de-dupe
across several radars on one lane, and ignoring non-presence inputs.

Full workspace build/lint/test green (185 server tests). Updated the
button-light-indicator wiki page ("On-screen twin").

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-28 11:48:12 +02:00