docs(wiki): activity-log explainability, dates/i18n, KP-300H barcode fix

Record this session's work across the affected pages + three log entries.

- ticket-encoding: id 13→11 digits (guess-resistance rationale, legacy-safe
  validation) + a barcode-geometry rule (symbol dots must fit the narrowest
  deployed printer's line — the KP-300H 72mm overflow).
- rongta-printer: KP-300H raster-garbage root cause (line overflow, not
  corruption), sendRaw graceful-close fix, Albanian human dates (formatStampSq).
- i18n: localized ledger reason codes, relative/human dates + the
  "browser ICU lacks Albanian" gotcha, toggle stale-router-context fix.
- shift: Albanian Z-report, shift-history UI + permission scoping.
- booth-console: explainable activity log (inline reasons/badges, event-detail
  modal with snapshots + audit disclosure, subscriber names, failed-snapshot
  tiles).
- index/log updated; all added wikilinks resolve.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-19 11:41:27 +02:00
parent bbf61c48df
commit 0074e82a2a
7 changed files with 154 additions and 20 deletions
+24 -1
View File
@@ -2,7 +2,7 @@
type: concept
tags: [parking, frontend, booth, realtime, ui]
sources: []
updated: 2026-06-18
updated: 2026-06-19
status: open
---
@@ -68,6 +68,29 @@ a right column with the **live event ticker**. Submitting/clicking a ticket open
modal** (entry/duration/total, tender, voucher checkbox, entry/exit snapshots). All live-refreshed via
the WS.
### Explainable activity log (2026-06-19)
The ticker used to flag an **anomaly** with no explanation — a red row with just an id, "nobody knows
what happened." Now every event is **self-describing and clickable**:
- **Inline reason + badges.** Each row surfaces the localized reason (from the signed `reasonCode` —
see [[i18n]]) and computed classification badges (entry/exit-refused, lot-full, barrier-failed,
manual-open…). Anomalies always show a reason line (or "no reason recorded") so a red flag is never
silent.
- **Clickable → event-detail modal.** A read-only modal with humanized labelled fields (not raw JSON),
the session's **entry/exit snapshots**, and the **signed-chain provenance** (signature / keyId /
prevHash) collapsed behind an "audit data" disclosure — operator sees the story, an auditor expands
for the crypto. *Why show signatures at all:* makes the [[append-only-event-chain|tamper-evidence]]
visible against the [[threat-model|booth-operator]] threat, and survives a signer swap (`keyId`).
- **Subscriber names, not opaque keys.** A [[subscription]] occurrence's `SUBSESS-…` id now renders as
the holder's name ("Aqif Kopertoni", fallback "Abonent"/"Subscriber"). Resolved **read-time
server-side** (events API + WS push attach a non-signed `subscriberLabel` from `permitId →
holder_name`; cached, invalidated on subscription edit/delete) so it needs no extra client
permission and the signed event stays minimal.
- **Failed-snapshot visibility.** A camera that was *attempted but unreachable* now shows a "⚠ camera
unreachable" tile (from snapshot [[device-events|telemetry]]) instead of a silent gap — so the
operator can tell "no camera" from "camera failed". (Surfaced a real incident: a subscriber's entry
snapshot failed `EHOSTUNREACH` while the exit one succeeded — by design a snapshot is *evidence, not
a gate*, so the open proceeded and only the image was missing.)
## The shift control (header) + the booth gate
The header carries a single **shift button** that expresses the [[shift|site-wide single-open
+44 -8
View File
@@ -2,7 +2,7 @@
type: concept
tags: [parking, frontend, i18n, localization]
sources: []
updated: 2026-06-18
updated: 2026-06-19
status: open
---
@@ -40,16 +40,52 @@ any booth. (Decided + built 2026-06-18.)
key). Keys are dot-namespaced by area (`common`, `nav`, `status`, `auth`, `booth`, `pay`, `shift`,
`site`, `permits`, `tariff`).
- **Translated screens:** the booth ([[booth-console]] — screen, pay/exit modal, active sessions,
snapshots, status), Login, ShiftControl, SiteSettings, PermitManager, TariffComposer.
snapshots, status), Login, ShiftControl, SiteSettings, PermitManager, TariffComposer,
**SetupWizard devices tab** (2026-06-19 — the static chrome; driver/config-field labels still come
from the backend catalog), Users/Roles managers, and the **shift-history** screen.
## Localized ledger reasons & relative dates (2026-06-19)
Two patterns added on top of the catalog approach:
- **Reason codes (backend i18n for the signed ledger).** Anomaly/payment/override reasons used to be
free-text **English baked into the signed `payload.reason`** — unlocalizable at render time (the
bytes are immutable). Now the ledger signs a **stable `reasonCode` + `reasonParams`** (plus the
English `reason` as a fallback) from a closed set `REASON_CODES` in `@parking/shared`. The UI
translates `reason.<code>` via the sq/en catalogs, so an Albanian operator reads Albanian **from the
same immutable event**, and adding a language is a catalog change with **no re-signing** of past
events. Legacy events (no code) show the signed English fallback. This narrows — but doesn't close —
the "server API error strings are English" gap noted below: the *ledger* reasons are localized; raw
HTTP error strings still aren't. See [[append-only-event-chain]], [[booth-console]].
- **Relative + human dates (`formatRelativeDateTime`).** Sessions/logs/history show **`Sot/Today
10:48` · `Dje/Yesterday 17:33` · `17 Qershor/June 10:48`** instead of a bare time (a 2-day-old
session previously showed only `10:48`). today/yesterday come from `common.today`/`yesterday`.
> **Gotcha — the appliance browser's ICU has NO Albanian locale data.** `Intl.DateTimeFormat("sq",
> {month:"long"})` silently returns **English** ("June", not "Qershor") on this hardware. So month
> names come from a **`common.months` catalog array**, NOT `Intl`. Any future date formatting on
> this box must avoid relying on `Intl` for Albanian or it leaks English. (Printed slips already
> solved this with the `SQ_MONTHS` table in [[rongta-printer]].)
## Open / deferred
- **SetupWizard is NOT translated** (deliberate). Its content is mostly **server-provided** — driver
labels and config-field labels/help come from the backend device-catalog API ([[device-registry]],
[[first-run-setup]]). Translating only its static chrome would leave a half-English screen; it's
deferred until **backend catalog i18n** is scoped, then chrome + catalog localize together.
- **Server API error strings** are still English (surfaced raw in the UI). v1 relies on the
client mapping known errors; a fuller approach would translate by error *code*, not message.
- **SetupWizard chrome is now translated (2026-06-19)**; the remaining gap is the **backend
device-catalog** (driver + config-field labels/help from [[device-registry]], [[first-run-setup]]),
still English. Localizing the catalog is the open item.
- **Server API error strings** are still English (surfaced raw in the UI). The **ledger reason codes**
are now localized (above), but raw HTTP error messages aren't. A fuller approach translates by error
*code*, not message — the reason-code pattern is the template to follow.
- **Behaviour note (not a bug):** a *hard navigation* (new URL) re-bootstraps the language from the
user's stored preference via `/me` — so an un-persisted toggle resets. Correct: the stored pref
wins. The toggle persists via the PUT, so it survives once saved.
## Gotcha — language/theme toggle stale router-context (fixed 2026-06-19)
The header SQ/EN (and dark/light) toggles read the active value from `user`, which comes from the
**TanStack Router context** (`rootRoute.useRouteContext()`). Router context is **captured at
route-resolution time and does NOT re-render on `setUser`** — so after *one* switch the toggle's
`user.language` froze, the active-button highlight stuck, and the equality guard blocked switching
*back* until a page refresh (which re-resolved the context). Fix: drive the toggles off **live**
state, not the stale context — language reads `i18n.language` (`useTranslation()` subscribes to
i18next's `languageChanged`), theme uses local `useState`. Both still call `setUser` to keep the
context eventually-consistent + persisted, but no longer *depend* on it re-rendering. General lesson:
**router context is not reactive React state** — never read frequently-changing UI state from it.
+16 -1
View File
@@ -2,7 +2,7 @@
type: concept
tags: [parking, domain, business, shifts, anti-fraud]
sources: []
updated: 2026-06-18
updated: 2026-06-19
status: open
---
@@ -77,6 +77,21 @@ login ————————————————————————
That's the whole human-side requirement: **print the cash and the POS (if any).** No blind count,
no variance gate, no manager override.
> **Z-report is now Albanian (2026-06-19).** The printed Z-report labels were hardcoded English
> (`Operator:`/`From:`/`Cash:`) with raw ISO timestamps; now fully Albanian (`Operatori`/`Nga`/`Deri`/
> `Para në dorë`/`-- Arka --`/`Arka e pritur`…) with the human date format `19 Qershor 2026 10:48:25`,
> shared via `formatStampSq` from [[rongta-printer]]. Consistent with the "[[i18n|printed paper is
> always Albanian]]" rule — independent of the operator's UI language.
### Shift history UI + permission scoping (2026-06-19)
A read-only **shift-history screen** (`GET /api/shifts`) lists completed shifts — each a signed
`shift_z_report` folded for its figures (no re-summing), newest-first, expandable to the drawer
reconciliation. **Scoped server-side by permission** (dynamic [[local-jwt-auth|RBAC]]): an operator
with `shift:read` sees **only their own** shifts (operator/date filters ignored); an admin-grade role
(`shift:cash`) sees **all** operators with an operator + date-window filter. The server hard-scopes
non-admins to `req.user.username` regardless of any `?operator=` param (verified: an operator passing
another's name returns `scope:self`, 0 rows). One screen, behaviour driven by the returned `scope`.
### As-built (2026-06-16)
- A shift is **two signed ledger events**, no mutable table (decision): `shift_open` (new event
+28 -6
View File
@@ -2,7 +2,7 @@
type: concept
tags: [parking, domain, business, devices, entry-flow]
sources: []
updated: 2026-06-15
updated: 2026-06-19
status: open
---
@@ -23,11 +23,19 @@ must have:
reconciliation aid ([[reconciliation]] pre-numbered stock), not the scan key.
- **All-numeric** (as-built 2026-06-17) — so ANY legacy 1D barcode scanner reads it and an operator
can hand-key it. Random (not sequential), so "all-numeric" does not weaken the unguessable
property. Format: **13 digits = 12 cryptographically-random digits + 1 Luhn check digit**
(10^12 space → negligible collisions at lot scale; the Luhn digit lets manual entry reject a typo
rather than fail as "session not found"). `newTicketId()` in `apps/server/src/entry-flow.ts`;
validate with `validateTicketCode()` (gate MANUAL entry only — a scanned/looked-up id already in
the ledger is authoritative regardless of format).
property. Format (**shortened 13→11 on 2026-06-19**): **11 digits = 10 cryptographically-random
digits + 1 Luhn check digit**. **Length is driven by guess-resistance, not volume** — with 10^10
valid ids and the Luhn digit rejecting 9/10 malformed guesses, a blind attempt at a currently-OPEN
ticket lands at ~1-in-10^7 even with thousands parked (the [[threat-model|booth-operator]] threat),
which is the property that matters; raw count (a billion) was never the constraint. Collisions stay
negligible at lot scale. `newTicketId()` in `apps/server/src/entry-flow.ts`; validate with
`validateTicketCode()` — now **length-agnostic** (`\d{10,14}` + Luhn) so legacy 13-digit tickets in
circulation keep validating. Gate MANUAL entry only — a scanned/looked-up id already in the ledger
is authoritative regardless of format.
> **Why 11, not the requested 9 (2026-06-19):** 9 digits (10^8 space) against ~1000 live tickets
> gives ~1-in-10^5 per blind guess — an operator scripting guesses could find a valid open ticket
> in minutes. 11 keeps a strong anti-forgery margin AND was the actual fix for a printer overflow
> (below). The shorter id is *why* the width-3 barcode now fits the 72mm Cashino line.
- **Format is a property of minting, not the schema** — `identity` / `sessions.id` are free-form
`text`, so changing the id format is a code change with **no migration**. Legacy `T-<uuid>` ids
(pre-2026-06-17) remain valid keys and coexist with numeric ones.
@@ -50,6 +58,20 @@ must have:
> uncertain, so the id is carried in two independently-readable forms (1D barcode / printed digits).
> The "operator scans with a phone" path reuses the
> existing dispatch flow ([[entry-exit-readers]]) and is tracked separately (not yet built).
> **Barcode GEOMETRY must fit the paper width — root cause of a real garbage-print incident
> (2026-06-19).** The Cashino [[rongta-printer|KP-300H]] entry dispenser printed tickets as **raster
> garbage** (solid black bars / banding) while the [[rongta-printer|Rongta]] printed the *identical*
> byte stream fine. Not data corruption: the **Code128 symbol overflowed the print line.** Math: a
> Code128-B symbol is `(11·chars + 35) · moduleWidth` dots. At 203 dpi the KP-300H prints **72mm =
> 512 usable dots** (the Rongta runs 80mm = 576). The old **13-digit** id at `GS w 3` = ~534 dots
> **overran 72mm**, and the firmware rendered the overflow as pixels; the Rongta's 80mm had just
> enough room — which is why only the Cashino failed. The **11-digit** id at width 3 = **~468 dots**,
> fits both widths, and scanned the full value at the exit reader (verified on hardware). Lessons:
> (1) keep `(11·len+35)·moduleWidth` under the **narrowest** deployed printer's usable dots; (2) a
> too-NARROW module (`GS w 2`) is also bad — it scanned but returned **truncated** values (partial
> reads logged as `exit.refused.noSession` anomalies). Width 3 + 11 digits is the verified sweet
> spot. `code128()` in `packages/devices/src/drivers/printer-escpos.ts`.
- **Scan points** (both host-side reads — [[entry-exit-readers]]):
- **Pay station** — customer scans the ticket → host finds the session → shows fee → takes
payment ([[tariff]], pay-on-foot) → appends `payment`.
+28 -2
View File
@@ -2,7 +2,7 @@
type: entity
tags: [parking, hardware, printer, device]
sources: []
updated: 2026-06-14
updated: 2026-06-19
---
# Rongta 80mm thermal printer
@@ -14,7 +14,16 @@ many ESC/POS-compatible OEM clones that share its firmware). Driver `rongta` in
## Transport & protocol
- **ESC/POS over a raw TCP socket on port 9100** (the JetDirect/RAW convention). The driver
opens the socket, writes the ESC/POS byte stream, waits for flush, closes.
opens the socket, writes the ESC/POS byte stream, and **closes GRACEFULLY**.
> **Graceful close — fixed 2026-06-19.** `sendRaw()` previously did `write(payload)` then
> `destroy()` on the write callback. But a `Socket.write()` callback fires when bytes reach the
> *local kernel buffer*, NOT when the peer has read them — so `destroy()` could send a TCP **RST**
> that truncates the job mid-stream, leaving the printer a desynced ESC/POS stream (raster garbage).
> Now it `end(payload)` (write + FIN, a clean half-close) and resolves on the socket's `close`
> event — which only fires after the printer has drained the bytes. A timeout *after* the write
> completed is treated as success (some printers never send their own FIN), so a delivered job is
> never spuriously failed. A latent bug found while diagnosing the KP-300H garbage; it was NOT the
> cause of that (see overflow gotcha below) but is a real truncation risk on its own.
- **No authentication** on the print socket — anyone who can reach port 9100 can print. Like
every other field device it must sit on the **isolated device VLAN** ([[network-isolation]]).
There is no real HTTP/control boundary on the device (same posture as [[dingtian-relay]]).
@@ -66,6 +75,23 @@ bytes. A printed voucher surfaced two encoding bugs, both now fixed in `printer-
`line()` now normalises U+00A0/U+202F → a plain space before encoding. Any Intl-formatted value on a
ticket is affected, not just money.
### KP-300H barcode overflow — only the Cashino garbled (2026-06-19)
The Cashino entry dispenser printed **raster garbage** on entry tickets while the booth Rongta printed
the *same* byte stream cleanly. Root cause was **barcode line-overflow, not corruption**: a 13-digit
Code128 at module width 3 (~534 dots) overran the KP-300H's **72mm = 512-dot** line; the Rongta's
80mm (576) had room. Plain-text-only prints were clean, isolating it to the `GS k` barcode. **Fix:
shorten the ticket id 13→11 digits** (~468 dots — fits 72mm) — see [[ticket-encoding]] for the
geometry. Module width must stay 3: a test at width 2 scanned but returned *truncated* values.
**General rule:** size firmware barcodes/QRs to the **narrowest** deployed printer's usable dot width.
### Human dates on printed slips — Albanian (2026-06-19)
`stamp()` now formats timestamps as **`19 Qershor 2026 10:48:25`** (Albanian month, 24h with seconds)
instead of `YYYY-MM-DD HH:MM`. Month names are a hardcoded `SQ_MONTHS` table (the appliance browser's
ICU lacks Albanian locale data — see [[i18n]]). `stamp` is exported as **`formatStampSq`** so the
shift [[shift|Z-report]] shares the one Albanian date format. Applies to ticket issue time, receipt
entry/paid rows, and subscription validity dates (date-only). Consistent with the
"[[i18n|printed paper is always Albanian]]" rule.
## Status
Driver written and compiles; entry-ticket layout is a first pass; live status monitoring is
+2 -2
View File
@@ -1,7 +1,7 @@
---
type: overview
tags: [parking, index]
updated: 2026-06-18
updated: 2026-06-19
---
# Index
@@ -90,7 +90,7 @@ Counts: 4 sources · 19 entities · 42 concepts · 5 decision records.
- [[validation-sponsorship]] — design: sponsor accounts + postpaid B2B (customers park free, business billed monthly); not a permit.
- [[reporting-analytics]] — revenue/occupancy/stay reports + plate-search, all projections over the signed log.
- [[clock-integrity]] — fees depend on the host clock; detect/flag backdating on an offline box.
- [[ticket-encoding]] — transient ticket id as QR; printed at entry, scanned at pay station + exit; plate-as-ticket alt.
- [[ticket-encoding]] — transient ticket id (11-digit numeric + Luhn) as Code128; printed at entry, scanned at pay station + exit; barcode geometry must fit paper width (KP-300H overflow); plate-as-ticket alt.
- [[anti-passback]] — block/flag one id entering twice without an exit; fold over open sessions.
- [[device-events]] — unsigned hardware telemetry (relay/printer/camera/reader/input); separate from the signed ledger.
- [[subscription]] — recurring plan (e.g. 10,000 ALL/month); RF/QR or plate identity, car-count + max-concurrent, host-in-loop; short-circuits payment. (Renamed from "permit"; time-of-day windows noted, deferred.)
+12
View File
@@ -880,3 +880,15 @@ A printed exit voucher (photo from the booth) surfaced three glitches in the new
## [2026-06-18] feat | Dynamic RBAC — composable roles + resource×CRUD permissions
Replaced the hardcoded role enum (admin/operator/cashier/readonly, checked literally as requireRole("admin",...) across ~15 routes) with DYNAMIC RBAC: roles are DATA, route guards check a PERMISSION. @parking/shared now defines a code-defined grid — RESOURCES (user/role/tariff/subscription/site/device/shift/payment/session/event/report) × Action (create/read/update/delete + domain verbs void/cash) → PERMISSIONS (resource:action). DB: new `roles` + `role_permissions` tables; users.role enum → role_id FK; migration 0007_rbac (create tables, seed builtin admin role + all 26 perms, seed operator/cashier/readonly composable roles matching old behaviour, rebuild users to swap the column copying all rows). auth.ts: JWT payload role→roleId; permissionsFor(roleId) with in-memory cache + bumpPermsCache(); requirePermission(...perms) preHandler (jwtVerify+CSRF+perm check); requireAuth for /me & /language; initAuth(db) wires the resolver once in buildServer. Every route guard mapped to a permission (tariff:read/update, payment:create/read, session:read, shift:read/create/cash, site:read/update, device:read, subscription:*, event:read; ws→report:read); device ingress (devices.ts/qr-reader.ts) stays auth-free by design. New routes/users.ts (user:* CRUD, bcrypt 12, last-admin guard) + routes/roles.ts (role:* CRUD, builtin-protected, perms validated against the grid, cache bump on write). auth/me + /login now return {roleId, roleName, permissions, language}. seed-admin.mjs → roleId:'admin'. Frontend: api.ts SessionUser carries permissions + can() helper + users/roles CRUD fns; router.tsx nav/route guards gate by permission (requirePerm factory replaces adminOnly), SiteSettings edit now gated by site:update; new UsersManager.tsx + RolesManager.tsx (permission checkbox grid; admin role read-only/locked); i18n nav.users/roles + users/roles blocks (sq+en, parity green). DECISIONS (with user): one role per user; protected built-in admin (no-lockout); JWT carries roleId, perms resolved per-request (role edits apply immediately). VERIFIED: full monorepo build green; a 20-assertion inject test (cashier 403s on tariff publish + user list, admin passes, granting tariff:update to the cashier role applies on the NEXT request = cache invalidation works, last-admin delete/downgrade → 409, builtin admin role edit/delete → 409) all pass; migration 0007 applied to a COPY of the live DB (incl WAL/shm) → existing admin maps to role_id='admin', 4 roles seeded, 26 admin perms, all user rows preserved. Updated [[local-jwt-auth]]. Append-only event chain untouched (event:void gates appending a void, not a delete).
## [2026-06-19] feat | Explainable activity log — reasons, subscriber names, snapshot gaps
The booth live-feed flagged anomalies with no explanation (a red row + an id). Made events self-describing + clickable. (1) **Localized reason codes (backend i18n for the signed ledger):** reasons were free-text English baked into the immutable signed `payload.reason` → unlocalizable at render time. Now the ledger signs a stable `reasonCode` + `reasonParams` (+ English fallback) from a closed `REASON_CODES` set in @parking/shared (entry.*/exit.*/sub.* groups + `reasonPayload()` helper; emitted from entry/exit/subscription flows). UI translates `reason.<code>` via sq/en catalogs — an Albanian operator reads Albanian from the SAME immutable event; adding a language = catalog change, no re-signing. Legacy events show the signed English fallback. (2) **Clickable rows → event-detail modal:** humanized labelled fields (not raw JSON) + entry/exit snapshots + signed-chain provenance (signature/keyId/prevHash) collapsed behind an "audit data" disclosure. Rationale for showing signatures: makes tamper-evidence visible vs the booth-operator threat, survives a signer swap. (3) **Subscriber names:** a SUBSESS-… occurrence renders the holder name (fallback "Abonent"/"Subscriber"), resolved read-time server-side (events API + WS push attach a non-signed `subscriberLabel` from permitId→holder_name; cached, invalidated on sub edit/delete). (4) **Failed-snapshot visibility:** the snapshots API returns failures[] from telemetry; UI shows a "⚠ camera unreachable" tile so a missing image isn't a silent gap (surfaced a real EHOSTUNREACH on a subscriber entry camera; by design snapshot = evidence not gate, so the open proceeded). Committed f31e57b. Updated [[booth-console]], [[i18n]].
## [2026-06-19] feat | Human + relative dates across UI and printed slips
Dates were raw ISO on paper and time-only in the UI (a 2-day-old session showed just "10:48"). (1) **Printed slips** (tickets/receipts/subscription cards): `stamp()` now formats "19 Qershor 2026 10:48:25" (Albanian month, 24h+seconds) via a hardcoded `SQ_MONTHS` table; exported as `formatStampSq` so the shift Z-report shares it. (2) **Z-report** is now fully Albanian (Operatori/Nga/Deri/Para në dorë/-- Arka --/Arka e pritur…), was English-only with ISO dates. (3) **Web** sessions/logs/history: `formatRelativeDateTime` → "Sot/Today 10:48" / "Dje/Yesterday 17:33" / "17 Qershor/June 10:48". GOTCHA: the appliance browser's ICU has NO Albanian locale data — `Intl.DateTimeFormat("sq",{month:"long"})` returns English ("June"), so month names come from a `common.months` catalog array, not Intl. Also FIXED a latent bug: the SQ/EN + dark/light toggles read the active value from TanStack Router context (`useRouteContext()`), which is captured at route-resolution and does NOT re-render on setUser — so after one switch the highlight froze + switching back was blocked until a page refresh. Now driven off live state (language from i18n.language via useTranslation; theme from local useState). Committed 00f3d14. Updated [[shift]], [[i18n]].
## [2026-06-19] fix | KP-300H barcode line-overflow — ticket id 13→11 digits
The Cashino KP-300H entry dispenser printed entry tickets as RASTER GARBAGE (solid black bars/banding) while the Rongta printed the IDENTICAL byte stream fine. Diagnosed on hardware: plain-text-only prints were clean → isolated to the `GS k` Code128 barcode. ROOT CAUSE = barcode line-overflow, not corruption: a Code128-B symbol is (11·chars+35)·moduleWidth dots; the old 13-digit id at module width 3 = ~534 dots OVERRAN the KP-300H's 72mm line (512 usable dots @ 203 dpi). The Rongta runs 80mm (576 dots) and had just enough room — why only the Cashino failed. FIX: shorten the ticket id 13→11 digits (10 random + Luhn) → ~468 dots, fits 72mm; scanned the full value at the exit reader (verified). Length is driven by GUESS-RESISTANCE not volume (10^10 space, ~1-in-10^7 to hit a live open ticket vs the booth-operator threat); chose 11 over the requested 9 (10^8 → ~1-in-10^5, too weak). validateTicketCode made length-agnostic (\d{10,14}+Luhn) so legacy 13-digit tickets still validate. NB: module width must stay 3 — a width-2 test scanned but returned TRUNCATED values (partial reads logged as exit.refused.noSession anomalies). Also fixed a separate latent transport bug in sendRaw: write-then-destroy could RST mid-stream (the write callback ≠ peer-flushed) and truncate a job; now end(payload)+FIN, resolve on socket `close`, timeout-after-write = success. NOT the cause of the garbage but a real risk. Committed bbf61c4. Updated [[ticket-encoding]], [[rongta-printer]].