Multi-tenant SaaS layered on the offline model: link-up monitoring of the
signed ledger, device status, financials; one admin → many sites; per-site
secret custody; recurring fee. Records the four tensions, the confirmed
secrets boundary (sync creds + device-password escrow + app identity, NOT
the signing key), and the two in-discussion corrections that stand (NetBird
already solves booth isolation; remote barrier-open is pulseOpen-and-signed,
driven by the unmanned future). status: open, postponed.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
The pay modal rendered entry via formatRelativeDateTime (relative day, no
seconds → "Sot 19:25") and exit/now via the legacy formatTime (raw
HH:MM:SS, no day → "19:25:44") — inconsistent on both day context and
seconds. Added a { seconds } option to formatRelativeDateTime and routed
all four call sites (entry, exit, live now, alreadyClosed toast) through
it, so every row reads "Sot 19:25:44". Removed formatTime — the last raw
toTimeString() helper and the source of the mismatch; BoothPayModal was
its only caller.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Both tables belonged to NO reset category and silently survived every
reset, --all included (the hand-maintained table list lagged the schema
twice). app_logs gets a new --diagnostics category; tariff_drafts joins
--config. A drift guard now compares the category union against
sqlite_master before doing anything and refuses on any uncategorized
table, so the next new table forces a deliberate one-line decision instead
of escaping by omission. Verified on a scratch DB: guard refuses a planted
table (exit 1), --all lists both new tables, --diagnostics wipes app_logs.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
A line identical to the last persisted row (level+source+message+path)
within a 5-min refreshing window updates that row — context._repeat counts
the fold, _firstAt keeps the first occurrence, createdAt tracks the latest
so the storm stays at the top of the newest-first viewer. A continuous
storm stays ONE row however long it rages, so it can't evict unrelated
history via the 50k row cap or grind the appliance disk. LogsViewer badges
coalesced rows ×N (tooltip: count + first occurrence, sq/en). In-memory
last-row cache only; a pruned-under-us row falls through to a fresh insert.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
An unreachable controller rejects the UDP send instantly, and #pump's
failure re-pump retried inline: a tight loop logging hundreds of identical
errors per minute (park-buzi, 2026-07-07). Failed sends now arm a 1s→30s
exponential retry (reset on success); desiredOn keeps tracking the truth
table meanwhile and the armed retry converges to it. Logging is
rate-limited: first failure of a streak in full, then one summary/minute,
one info line on recovery. #finalOff waives the backoff so the last-gasp
OFF on drop/shutdown still gets an immediate try.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
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
Adds on top of the un-deployed cd3b534 pin: camera clock sync via ISAPI
(heals the 1970 power-cut reset at the offline→ready edge + daily
backstop). Everything since the deployed f9887c2 rides along: USB
printer close-cancel fix (hardware-verified at the lab), USB device
dropdown (lp1 shows by model name), printers addable without a
controller. No migrations.
Post-deploy validation: pull a camera's power, let it come back, then
docker logs | grep "clock synced" — expect a warn with a huge drift.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
park-buzi field observation: after a power cut the Hikvision cameras
reboot at the 1970 epoch (no/dead RTC battery, no NTP) and stay there
until a human logs into the web UI (which silently pushes the browser
clock) — corrupting the snapshot OSD timestamps (the evidence trail) and
ANPR push times meanwhile.
The host is the site's time authority (offline-first, no NTP infra):
- Device monitor triggers a sync at each camera's offline→ready edge —
exactly the power-restored moment — plus a 24h backstop; the attempt
is stamped before the async call so a failing camera retries at
backstop cadence, never every poll.
- HikvisionCamera.syncClock: GET /ISAPI/System/time; drift ≤60s → leave
alone; beyond (or unparseable = infinite drift) → PUT timeMode=manual
with the site wall-clock now WITH explicit utc offset
(localIsoWithOffset), echoing the camera's timeZone verbatim — correct
the clock, never fight its tz/DST config.
- Jumps >1h (the power-cut signature) log warn (persisted to app_logs);
small corrections info. Capability-guarded (isClockSyncable) —
hikvision only; dahua's CGI has no such endpoint.
- http-digest generalised to digestRequest (GET/PUT/POST + body); the
handshake was already method-aware. digestGet delegates unchanged.
8 new tests: in-sync no-op, 1970 PUT shape (manual + host instant +
echoed tz), unparseable→sync, failed-set surfaces, dahua non-capability,
DST-both-sides pins on the offset formatter.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
The kernel numbers usblp nodes by plug/boot order (park-buzi's printer
is lp1); the wizard hardcoded lp0 in labels/default and the admin had to
shell in and `ls /dev/usb`. Now:
- GET /api/setup/usb-printers enumerates /dev/usb/lpN (visible via the
compose bind-mount) and enriches each with the printer's self-reported
make/model from sysfs ieee1284_id (readable through Docker's ro /sys).
- The wizard's devicePath becomes a SELECT of printers actually present
("/dev/usb/lp1 — Xprinter XP-K200L"): a fresh form preselects the
first real device; a saved-but-unplugged path stays selectable,
flagged "saved — not present now"; zero found falls back to free text
+ a check-the-cable hint.
- Transport option label no longer hardcodes lp0.
Wiki: printer-usb-transport marked HARDWARE-VERIFIED (lab 2026-07-07:
full slip + feed + cut over USB — parity with TCP).
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
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
Second [[stack]] block: server park-test (the lab box's Periphery
connect_as), compose files from the dev branch, MOVING TAG=dev (a lab
may float; real booths pin), its own park_lab_* secret refs (per-box
blast radius — never shared with a real booth). park-buzi is untouched
on stage + pinned.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Second half of the printer/relay decoupling: the category section's
add-button gate ("add a controller first — a printer points at one of
its relays") blocked every non-access category while zero controllers
existed — hit on the lab bench (USB printer test, no relays on hand).
Printers don't bind (role + failoverRank route jobs), so the gate now
exempts them like the form's requirement already does.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Lab box (park-test) crash-looped: panic writing the agent key to
/etc/komodo/keys/periphery.key (Permission denied). Gotcha #9 was framed
as a hand-config hazard; v2.2.0's installer now writes the system-style
default even with --user. §7a: verify root_directory after every
install + sed fix + reset-failed/restart; user-unit vs sudo note; the
onboarding key survives a pre-connect crash.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Demoting the operator on park-buzi failed with "sanitize_string: invalid
characters in 'park-operator'" — Ubuntu's perl adduser/deluser tooling
rejects the hyphenated username. §5c now prescribes gpasswd -d for
sudo/lxd/lpadmin (shadow-suite, no perl sanitize) and documents that
group removal lands at NEXT login: the auto-login operator session keeps
its old memberships until reboot/relog, so verify `groups` from inside
the session afterwards.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Field failure on park-buzi: reset-db --users wipes the roles table and
points to seed-admin — which inserted the user with roleId "admin"
without recreating the role row (migration 0007 never re-runs), dying on
the role_id FOREIGN KEY. The script now upserts the built-in admin role
first (the row alone suffices — admin permissions resolve in code).
It also appends a SIGNED config_change (admin.passwordReset /
admin.seeded, operator console:seed-admin) via the server's compiled
EventLog + signer: a console seed/reset by the Linux admin can't be
gated by the app, but it stays attributable in the chain. Best-effort —
no build/signing key warns loudly and proceeds (locking an admin out to
protect an audit line would invert the priority). Both paths verified
against a scratch DB reproducing the post-reset state.
Runbook: appliance-provisioning §7e — lost app-admin password reset via
FORCE=1 (interactive preferred; sessions not revoked → rotate JWT_SECRET
if theft suspected); §7d notes the FK failure + self-heal.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
"ALL 740 / 3h 2m" gave no derivation. explainFee in @parking/shared runs
the EXACT computeFee walk with an optional trace collector — one code
path, so Σ line items ≡ the amount by construction (golden V1 regression
byte-identical; instrumentation changes no fee). Items: contiguous
same-price increment runs (time window · N × unit · tier-card name),
window-package occurrences, stepped day totals (top-tier repeat
flagged), daily-cap clamps as NEGATIVE adjustments, entry grace.
/api/tariff/simulate returns `breakdown` (null when settled); the lab's
Outcome panel renders the lined table with a rounding note (raw min →
billed min at the increment — answers "why does 3h 2m bill as 4h") and
a total row. Works against active/historical versions and drafts alike,
so a night-package draft can be verified line by line before publish.
Largely delivers the wiki's open "composer price preview" item.
4 new engine tests pin the sum invariant + item shapes (97 shared green).
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Ladder/flat prices are PER BILLING INCREMENT, but the form said only
"Çmimi / interval" — so changing the increment 60→10 silently multiplied
every price ×6 (operator walked into it). Now:
- Price headers name the real unit live: "Çmimi / orë" at 60,
"Çmimi / N min" otherwise (flat-mode radio label likewise).
- Amber warning whenever the increment ≠ 60: every price below is
charged per started N minutes, NOT per hour.
- Per-row "= X / orë" equivalence next to each ladder/flat price when
the tick isn't an hour — the multiplication nobody should do mentally.
- Example defaults are currency-scaled: ALL gets 200/100 ladder, 200/500
up-to, 2000 lost ticket (the old "2.00/1.00" euro-scale examples read
as 2 lekë/hour); EUR/USD keep 2/1/5/20. Threaded through empty forms,
new tier rows, and mode-switch templates alike.
Band DURATIONS stay in hours — real wall time, increment-independent.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Dates were a mix: catalog-formatted "25 Qershor 20:01" where screens used
formatRelativeDateTime, and browser-locale "7/6/2026, 9:34 AM" in ~20
places that called raw toLocaleString/-Date-/-Time-String. Unified:
- common.monthsShort in both catalogs (Jan/Shk/…/Qer/Korr/…/Dhj);
formatDate ("25 Qer", year only when not current), formatDateTime
("25 Qer 14:30", optional seconds), formatClock ("HH:mm", 24h) in
lib/format.ts. formatRelativeDateTime keeps Sot/Dje and switches its
older-dates branch to the same short months.
- Every raw toLocale* DATE call swept: shifts X-report line, plan
effective dates, sub version labels, drawer today feed, snapshot
tooltips, device footer checkedAt, event-detail timestamp (keeps
seconds — chain evidence), tariff composer active-since + version
sidebar. Number toLocaleString (thousand separators) untouched.
The catalogs in this commit also carry the keys for the two follow-up
commits (fee breakdown, composer increment labels).
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
"Cilën barrierë shërben kjo pajisje?" is load-bearing for readers and
cameras (which barrier a scan opens + inherited direction) but nothing
consumes it on a printer — print routing is role + failoverRank
(printer-routing.ts). The wizard applied the requirement to every
non-controller device, so adding a printer demanded a meaningless relay
pick that got stored as dead config.
Printers are now exempt: no requirement, the binding panel is hidden,
the binding is not persisted (a stale pre-fix one drops off on next
edit), and the device list shows the printer's ROLE instead of a bogus
amber "unbound". Server never validated it — no API change.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
The reachability-only clone driver carried its first unit's vendor name,
which read as misleading in the setup UI once other clones (ICS/Xprinter
XP-K200L, verified 2026-07-06: no /prn_stat.htm) used it. It was always
the generic ESC/POS driver — now named so:
- printer-cashino.ts → printer-generic.ts; GenericEscposPrinter;
id "escpos", label "Generic ESC/POS 80mm printer (Cashino,
ICS/Xprinter…)".
- Migration 0023 rewrites stored devices.driver_id rows.
- The registry keeps a PERMANENT cashino→escpos alias so restored
pre-rename backups still resolve instead of "unknown driver".
Prose mentions of the Cashino as physical hardware stay — it's a real,
verified-fit printer; only the driver identity stopped being vendor-named.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
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
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
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
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
The device monitor logs + re-emits a status only when state OR detail
changes, but the camera probe's detail was the exact snapshot byte count,
which differs on every JPEG frame — so healthy cameras "changed" on
nearly every poll, writing a log line + websocket event each time
(inflating the freshly budgeted container logs). The detail is now a
stable power-of-two bucket ("snapshot ≈16 KB" / "≈256 KB") that moves
only on a real shift (stream/resolution change); an empty-ish 200 body
is flagged as "<1 KB" rather than bucketed away. Failure details
(auth/HTTP/timeout) unchanged. 3 tests pin the no-flap behavior.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
/drawer was record + review only: no current balance, no sight of the open
shift's incomings, no daily activity, no shift history. Rebuilt as a hub:
- Drawer now: the till's running balance (new GET /api/drawer/balance,
shift:read — exposes the service's existing drawerBalance(); the drawer
is one site-wide till, same exposure the X-report already had) with the
open shift's X-report breakdown alongside (float + takings + vouchers =
expected = balance) and a "This shift: ±X" figure (expected − opening
float — the shift's own contribution vs what it inherited).
- Today's cash activity: every cash payment + voucher since local
midnight from the signed chain, live, with day totals (card never
enters the till).
- Record + movements/review: the 2026-07-01 flow, unchanged.
- Closed shifts: drawer-focused history via the scope-aware /api/shifts
(float → takings ± vouchers → expected per shift).
Also: every shift open/close button (header, /shifts, pay modal, end-
shift confirm) now shows an animated spinner + dims while busy — the old
label-swap-only feedback read as a dead click when a shift open ran slow.
The slowness itself (drawer/shift reads fold the WHOLE chain, O(chain))
is recorded as an open item in wiki/concepts/shift.md with the fix
sketch: fold from the last z-report's signed expectedDrawerMinor forward.
No new ledger surface — one read-only endpoint; RBAC test added.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Currency was free text in the tariff editor (composer page + lab draft
modal — shared form) and the subscription plan editor; a typo could
publish an unknown code onto immutable versions. Both now offer a closed
select from lib/currencies.ts. An out-of-set code already stored on an
old record is appended as an extra option so it displays + round-trips
unchanged. Blank tariff form defaults to ALL (was EUR) — the site's
actual currency.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
The admin operator filter was a free-text input that broke three ways at
once: its visibility hangs off the query response (scope === "all") and
its value is part of the query key, so every keystroke started a new
query, data went undefined for the round-trip, and the input UNMOUNTED
mid-keystroke (lost focus, list blanking that read as a page reload).
Filtering also silently failed — the server matches the operator by
exact username, so partial text matched nothing.
- keepPreviousData on the shifts query: previous data (and scope) stays
live during refetch, so filter controls never unmount and the list
never blanks on preset/filter changes.
- The filter is now a <select> of operators that HAVE shifts: the server
returns the distinct list (signed z-reports + the open shift's holder)
on GET /api/shifts, admin scope only — operators still can't see other
names. Exact match by construction.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
The booth is an offline appliance — no webfont CDN — so the font ships
from public/fonts/chakra-petch: latin subset (covers en + sq ë/ç), the
weights the UI actually uses (400/600/700 + 400 italic, ~40 KB total),
SIL OFL license alongside the files. Chakra Petch leads all four family
tokens (mono/display/ui/body) with the previous stacks kept as fallback;
index.html preloads the two everywhere-weights so first paint doesn't
flash the fallback. Not a true monospace — .num/.tabular still request
tabular figures and columns verified aligned in the built app.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Experimenting used to mean publishing — churning the immutable version
history and risking real tickets pricing against a half-baked card while
the admin iterated. The lab is now a true sandbox:
- tariff_drafts table (migration 0021): MUTABLE by design — the one
exception to "editing publishes a version"; a draft prices nothing and
signs nothing. Drafts are validated + tz-stamped on save exactly like a
publish, so a saved draft always simulates and never fails at publish.
- CRUD under /api/tariff/drafts (list tariff:read, mutations
tariff:update); publishing a draft goes through the normal immutable
POST /api/tariff/versions path.
- Lab UI rebuilt: sidebar lists lab drafts AND the full published history
(click any to price against it); main pane cut to pure entry/exit
(ticket loader, payment, category inputs dropped); the composer form is
extracted to TariffEditorForm.tsx and reused in a modal (new drafts
prefill from the active card); per-draft Publish with confirm.
- tariff_versions.name (migration 0022): optional label stamped at
publish — carried from the lab draft, or typed in the composer's new
optional field — so history reads "Winter 2027", not UUID prefixes.
- Includes the composer UI + sq/en labels for the package mode (engine
landed in d9e6c13) and the "Flat price / hour" relabel.
5 new server integration tests (RBAC, roundtrip, validation, tz-stamp +
simulate + publish w/ name); server suite 288 green.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
The lab lived at /subscriptions/tariff-lab — the wrong neighborhood for a
tool that tests the rate card. /setup/tariff is now a small layout with
two sub-tabs (composer at the index, lab at /setup/tariff/lab) behind the
existing tariff:read gate. Old URLs (/subscriptions/tariff-lab and the
original /setup/tariff-lab) redirect, and the tariff-read-only redirect
branch on /subscriptions is gone with the tab.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
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
CI flake root cause (Gitea runner, anpr-entry.test.ts "records an advisory
anpr-skip"): the poll-until-confident loop was a plain
`while (Date.now() < deadline)` — zero iterations were possible when the
window elapsed between deadline-set and loop-entry (the tests run a 5ms
window; a slow runner loses that race). Zero attempts → no frame analyzed →
"gave up" → no anpr-skip row → assertion fails. Not a regression: nothing in
the recent merges touched this path; the race existed since the poll loop
was built.
The invariant is real beyond tests: on a sufficiently loaded booth the old
loop could silently drop a real car's detection the same way. The loop is
now do-while (exit via the existing breaks: confident read, or next tick
past the slid deadline/hard cap), so a detection ALWAYS analyzes at least
one frame.
New regression test forces ANPR_POLL_WINDOW_MS=0 (the CI scenario, made
deterministic) and asserts exactly one capture attempt + the recorded skip.
Suite 283 green.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V