Files
parking_solution/wiki/concepts/shift.md
T
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

372 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
type: concept
tags: [parking, domain, business, shifts, anti-fraud]
sources: []
updated: 2026-09-05
status: open
---
# Shift (manned mode) & the Z-Report
A **shift** is one operator's accountability period at a manned booth: from the moment they take
over to the moment they hand over, however long that is. At the end, the system signs and **prints
a Z-report** — the cash and POS totals taken during the shift. (Decisions 2026-06-15.)
## Shifts exist ONLY in manned mode
A shift is fundamentally a **human accountability boundary** — "this person was responsible for the
takings from here to here." In the [[autonomous-direction|fully-automated / unmanned]] system there
is **no operator and no shift**; what replaces it is the pay station's **cash-collection cycle**
(who emptied the vault, when, how much vs. what the signed log expected) plus ongoing
[[reconciliation]] — a separate concept, not a shift. So shifts are scoped to manned operation;
don't force one model across both.
## Site-wide single-open + the booth gate (decided + built 2026-06-18)
> **Superseded 2026-09-05 — now PER TILL (built).** With money-taking venue modules (Car Wash
> at the bay), "site-wide" became **per till**: one open shift and one drawer per till
> (`booth`, `carwash`, …), each with its own operator, float, vouchers and Z-report; every
> money event names its till. See §"Tills" below and [[venue-modules]] §"Tills". Everything
> in this section stays true *within* a till.
A shift is a **site-wide accountability period**: at most **one shift may be open at a time** across
the whole appliance. This is what makes a taking unambiguously attributable — every payment/exit
falls inside exactly one operator's window. Consequences:
- **Login ≠ shift.** An operator may log in **off-shift** (e.g. to review their own past activity);
logging in never opens a shift. Conversely a shift can't be opened by two people at once.
- **Opening is refused when ANY shift is open** — whether the operator's own (double-open) or
*another* operator's (handover not done). `ShiftService.open()` checks `currentOpenShift()` (the
single site-wide open shift = most recent shift event on the whole chain is a `shift_open`), and
throws `ShiftAlreadyOpenError` carrying `heldBy` so the UI can name who holds it. Operator B can
only start once operator A closes — that's the handover.
- **The booth money path is GATED on an open shift.** `/api/pay`, `/api/exit`, `/api/voucher`,
`/api/barrier/reopen` run a `requireShift` preHandler that 409s `{ code: "no_shift" }` when none
is open. Read-only lookups (`/api/session/:id`, `/api/sessions/active`, `/api/pay/quote`) stay
ungated so the modal can still *display* a session and prompt "open a shift". The server is the
enforcement point; the UI mirrors it (see [[booth-console]]).
- **"Operate under someone else's shift" is deliberately disallowed.** B's takings would land in A's
Z-report and corrupt the attribution, so B is fully blocked until B's own shift is open.
- **Logs are per-shift.** The booth live feed shows only events from the open shift's window
(`GET /api/events?since=<shiftStart>`); no shift open → no feed, just the "open a shift" prompt.
## A shift is NOT time-based
It is delimited by **explicit operator action**, never by a clock:
- Booth reality: relief comes late, doesn't show, or one operator is **forced to work two shifts in
a row**. A fixed 8h boundary (or an 8h token expiry) would be wrong — it could strand an active
operator. So the [[local-jwt-auth|login token has no time expiry]] (valid until logout).
- **Start Shift / End Shift are explicit, and independent of login.** One login can span many
shifts; a back-to-back double is simply *End Shift → Start Shift again*, no re-login. The
operator (the same person or the next) marks the boundary.
```
login ——————————————————————————————————————————————→ (until logout)
[Start shift] … takings … [End shift→sign+print Z] [Start shift] … [End shift] …
```
## What End Shift does
1. Determine the shift's payment set: the signed `payment` events ([[parking-session]],
[[append-only-event-chain]]) between this shift's start mark and now. This includes a
**[[subscription]] sale fee** an operator collects during the shift (selling/renewing at the booth
appends a signed `payment` with `subscriptionSale: true`, amount `priceMinor × months` — **built
2026-06-20**) — it folds into this set like any transient taking, no special-casing. *(Before that
date subscription sales appended nothing, so the cash was off the Z-report entirely — a real
[[threat-model]] hole; see [[subscription]] "Collecting the fee".)*
2. Sum by **tender**: `cashTotal`, and `cardTotal` from the POS/terminal **if a POS is configured**
(the card line is omitted when there's no terminal).
3. Append a signed **`shift_z_report`** event (type already in `packages/shared`): `{ operator,
startedAt, endedAt, cashTotal, cardTotal?, paymentCount, eventRange, prevZHash }` — chained to
the prior Z so a missing/out-of-order Z-report is itself visible.
4. **Print the Z-report** (cash total, POS total if any, counts, shift window, operator) on the
booth printer.
That's the whole human-side requirement: **print the cash and the POS (if any).** No blind count,
no variance gate, no manager override.
> **Takings split by SOURCE + confirm-before-close (2026-06-21).** Two related changes:
> 1. The report now splits takings into **Tickets** (transient) vs **Subscriptions** (monthly
> `subscriptionSale` + a subscriber's out-of-window `subscriptionWindowCharge`), so the operator
> sees subscriber money apart from ticket money. The buckets are derived from the signed payment
> payload flags and always reconcile to `cash + card` (a payment with neither flag is a ticket).
> Computed once in `#summariseWindow`, carried on the signed `shift_z_report` payload
> (`ticketTotalMinor`/`subscriptionTotalMinor`/`subscriptionSalesMinor`/`subscriptionWindowMinor`),
> shown in the X-report, the close modal, the history detail, and the printed Z-report; old reports
> that predate the fields default subscription to 0 (ticket absorbs the whole take).
>
> **Display simplified (2026-06-28).** All four figures stay in the SIGNED payload (audit data —
> untouched), but the operator-facing breakdown was trimmed: the `shitje` (subscription-sales)
> sub-line was **removed** from the close modal, the X/Z-report views, and the printed slip —
> `Abonime` is the subscription total, with only the out-of-window part broken out under it (it
> was confusing to show both halves under a total). The **opening cash** (`openingFloatMinor`,
> labelled "Arka fillestare") was **added** to the drawer block so `opening + cash-taken = expected
> drawer` reads explicitly.
> 2. The **header shift button no longer closes directly** — a stray click would sign an irreversible
> Z-report. It opens a **confirm modal showing the live X-report** (the source split + expected
> drawer) with Cancel / End-shift. Opening a shift stays immediate (no such risk).
> **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
type) at start, `shift_z_report` at close. The operator is the **logged-in user**, carried in the
event `identity`. `ShiftService` (`apps/server/src/shift-service.ts`).
> **Superseded 2026-06-18:** open-ness is now judged **site-wide** (`currentOpenShift()` — the most
> recent shift event on the *whole* chain), not per-operator. See "Site-wide single-open" above.
> `openShiftFor(operator)` survives only for `close()` (you close your own shift).
- **Close** sums `payment` events in `[startedAt, endedAt]` by tender (cash vs. card, by **payment
time**), appends the signed `shift_z_report` (totals + counts + window), then **prints** via the
new generic `PrinterDevice.printReport(title, lines)` (Rongta ESC/POS text) to a booth-receipt
printer. Printing is best-effort — a failed print does **not** undo the signed close (the event is
the record; `printed:false` is returned).
- **Routes** (`routes/shift.ts`, cashier/operator/admin): `GET /api/shift/current`,
`POST /api/shift/open` (409 if already open), `POST /api/shift/close` (409 if none open).
**UI** `ShiftControl` in the app shell (non-readonly): Start/End + the Z-report totals.
- Verified: open → double-open 409 → payments (cash+card, one dated outside the window excluded) →
close totals correct + signed + printed → close-again 409 → re-open works; readonly 403;
verifyChain ok.
## Drawer balance — opening float, cash movements, carry-over (decided 2026-06-18)
The Z-report's payment totals answer "how much did this shift *take*?" — but a manned booth also has a
**physical cash drawer** that carries across shifts. The drawer is tracked as a running balance over
the signed chain, so each shift knows what it **inherited** and what it should **hand over**.
**The events (drawer vouchers — re-modelled 2026-06-20):** the original design used one signed
**`cash_movement`** event with a *signed* `amountMinor` (+ load / − removal). That conflated two
distinct financial documents into a `±`. In accounting a pay-in and a pay-out are different vouchers
(in Albanian: **Mandat Arkëtimi** = receipt, **Mandat Pagese** = disbursement), so the direction now
lives in the **event type**, not the sign of an amount:
- **`cash_in`** (*Mandat Arkëtimi* — a **receipt / pay-IN**): cash enters the drawer. `amountMinor` is
a **positive magnitude**. Voucher no. `AR-NNNN`.
- **`cash_out`** (*Mandat Pagese* — a **disbursement / pay-OUT**): cash leaves the drawer.
`amountMinor` positive; the fold subtracts it. Voucher no. `PA-NNNN`.
- Payload: `{ amountMinor (positive), reason, currency, operator (who recorded), voucherNo }`. Each
prints a **slip** (Albanian, like every operator-facing paper).
- **Authorization model (redesigned 2026-07-01): operator RECORDS freely → admin REVIEWS after.** See
"Drawer review" below. (Superseded the 2026-06-20 *operator-raised / admin-authorized-at-creation*
scheme, where the operator typed an admin's password inline — that blocked the operator until an
admin stood at the booth, and it lived on `/shifts`.)
- **Legacy `cash_movement` stays valid.** The type is retained; historical signed events on the live
chain still verify and still fold into the drawer (signed-± as before). Only *new* movements use the
voucher pair. The append-only chain is never rewritten.
- The existing `payment` events already add cash to the drawer (cash tender only; card never touches
the drawer).
**The math — drawer is a fold over the chain BY TIME, not by operator** (whoever holds the drawer at a
given instant is accountable for its running balance, regardless of who recorded each movement):
```
expectedDrawer(at) = Σ cash payments (tender=cash) up to `at`
+ Σ cash_in amounts (positive) up to `at`
− Σ cash_out amounts (positive) up to `at`
+ Σ cash_movement amounts (legacy, signed) up to `at`
```
**`cash_review` is NOT in this fold.** A review decision never moves cash — so it's excluded from the
drawer math by construction (see "Drawer review").
A shift's **opening float = expectedDrawer(shiftStart)** — i.e. everything that happened to the drawer
before this shift's start mark. It is **auto-inherited from the chain** (no operator entry). The
first shift ever opens at **0**; the admin's load makes it 5000.
**The Z-report at close** reports the full drawer picture for the shift window `[start, end]`:
`openingFloat`, `cashTakenMinor` (cash payments in-window), `cashAddedMinor` / `cashRemovedMinor`
(movements in-window), and `expectedDrawerMinor = openingFloat + cashTaken + cashAdded − cashRemoved`.
That `expectedDrawer` is exactly the **next** shift's opening float — the carry-over.
**Worked example (the canonical scenario):**
| Step | Event | Drawer |
| --- | --- | --- |
| Opening day | `cash_in` (Mandat Arkëtimi) +5000 | 5000 |
| Shift 1 takes 6500 cash | payments | 11500 |
| Shift 1 closes | Z: open 5000, took 6500, expected **11500** | 11500 |
| Shift 2 opens | opening float = **11500** (inherited) | 11500 |
| `cash_out` (Mandat Pagese) 5000 | withdrawal | 6500 |
| Shift 2 takes 4500 cash | payments | 11000 |
| Shift 2 closes | Z: open 11500, took 4500, removed 5000, expected **11000** | 11000 |
| Shift 3 opens | opening float = **11000** | … |
Card payments are excluded from the drawer (they settle to the bank, not the till). The drawer figure
is **expected**, not counted — the optional blind-count enhancement below would record the *variance*
against it.
## Drawer review — operator records freely, admin reviews after (2026-07-01)
The drawer feature was reworked from **synchronous admin-authorization-at-creation** (an admin had to
type their password at the booth for every receipt/disbursement) to **operator-records → admin-reviews-
after**. This removes the friction while keeping accountability.
- **Record (`drawer:create`).** An operator RECORDS a `cash_in`/`cash_out` freely — no admin sign-off
at creation. It **counts in the drawer immediately** (the cash physically moved). The permission is
**per-role and admin-revocable** in the Roles UI: an admin can turn off an operator's ability to
record at all. `POST /api/drawer/movement`.
- **Review (`drawer:review`, admin-grade).** Each movement is `pending` until an admin **authorizes**
or **denies** it. The decision is a new **signed `cash_review`** event `{ refId, decision, reviewedBy,
note? }` — append-only, so the decision itself is auditable. `GET /api/drawer/movements` (operators
see only their own; reviewers see all + a status filter = the pending queue) and
`POST /api/drawer/review`. One decision per movement (re-review rejected).
- **A denial is a FLAG, not a reversal — this is the load-bearing design choice.** Denying a movement
does **NOT** append a reversing cash event and does **NOT** touch the drawer balance. It's a judgment
about the operator ("this disbursement wasn't genuine"); crediting/debiting them is the **admin's/
accountant's job, outside this system**. We deliberately do **not** build accounting here — just a
simple running balance.
> **Why deny ≠ reversal (the cross-shift argument).** The drawer folds BY TIME across shifts. If a
> denial appended a reversal, it would land in whatever shift is open **when the admin clicks** — which
> can be a **later** operator's shift, after the reviewed shift already closed and Z-reported. That
> would make operator 2 accountable for correcting operator 1's mistake. By making review a pure flag,
> the correction never enters the ledger, so it **cannot leak into the next operator's drawer**. The
> next operator simply inherits the real physical balance (which they count at shift open) and carries
> on. This is verified by a regression test (`shift-service.test.ts`: op1 disburses → closes → op2
> inherits → admin denies → op2's drawer unchanged).
- **Home.** The feature moved OFF `/shifts` to its own top-level **`/drawer`** route (operator: record
+ own movements; admin: the review queue + all movements). `/shifts` is now just open/close +
Z-report. Server: `routes/drawer.ts` (lifted out of `routes/shift.ts`); UI: `DrawerManager.tsx`.
## Drawer hub — the page answers "what's in the till and why" (2026-07-05)
Operator feedback: `/drawer` was **too simple** — record + review only, with no current balance, no
sight of the open shift's incomings, no daily activity, no shift history. Rebuilt as a hub of five
panels, all reads over data that already existed:
- **Drawer now** — the running balance (new `GET /api/drawer/balance`, `shift:read`; a passthrough to
the service's existing `drawerBalance()`, which was never exposed). While a shift is open, the
X-report breakdown sits beside it so the number is always explainable: *opening float + cash takings
(with payment count — the "current shift incomings") + vouchers in − out = expected = balance*, and
a **"This shift: ±X"** figure under the balance shows the shift's OWN contribution (expected −
opening float), separating what this operator moved from what they inherited. With no shift open it
reads as the carried-forward closing balance.
- **Today's cash activity** — every drawer-touching signed event since local midnight (cash payments +
vouchers; card never enters the till), live (15s), with day totals. Source: the existing
`/api/events` window query, filtered client-side (`event:read`).
- **Record** + **movements/review** — the 2026-07-01 flow, unchanged.
- **Closed shifts** — drawer-focused history via the existing scope-aware `/api/shifts`
(operators: own; admins: all): float → takings ± vouchers, expected drawer per shift.
Visibility note: the balance endpoint is `shift:read` on purpose — the drawer is a **single site-wide
till**, the same exposure the open shift's X-report already had, not per-operator data.
**Busy feedback on shift buttons (2026-07-05).** Opening a shift can take seconds (see the open item
below), and the buttons' only feedback was a label swap ("Opening…") — subtle enough that operators
read a slow open as a dead click. Every shift open/close button (header, /shifts, the pay-modal's
"open shift now", the end-shift confirm) now pairs the busy label with an animated spinner
(`ui/Spinner.tsx`, reusable) and dims while disabled.
## Tills — one shift and one drawer per money-taking desk (built 2026-09-05)
A **till** is a physical cash drawer with its own accountability. The booth is the till that
always existed; a venue module that takes money at its own desk declares its own till in its
manifest (Car Wash → `carwash`; a future Bar → `bar`). Rules:
- A shift is **opened on a till**. At most one shift open per till; tills are independent (the
booth and the wash desk run side by side, by different — or the same — operators).
- **Every money event names its till** (`payload.till`): parking `payment` and the
subscription sale = `booth` (a wash paid at the booth rides the parking payment as
`chargeLines`, so it is booth money too); `carwash_payment` at the bay = `carwash`;
`cash_in`/`cash_out` carry the drawer they moved. `shift_open`/`shift_z_report` carry theirs.
- **Absent `till` = booth.** Every event before tills existed is booth money, so the chain
re-folds identically and old Z-reports read as booth shifts. `tillOf()` in `@parking/shared`
is the one place this rule lives.
- The drawer fold, the X/Z-report window (payments **and** vouchers) and carry-forward all
filter by till: the wash operator's expected drawer is *their* float + *their* bay cash +
*their* vouchers, and the booth's never includes bay money. The counted-vs-expected moment
therefore sits with whoever holds the cash — which is the whole point (see §below).
- "Take money at the bay" requires the **carwash** shift, not the booth's; the wash desk
carries its own shift control. The header button stays the booth's. The shift hub lists
every open shift with a till badge; the drawer hub switches tills.
- **Each desk's money is guarded by that desk's own permissions** (2026-09-05, after the
user found a wash user could open the *booth's* shift; design on [[venue-modules]]
§"Permissions matrix"). The manifest declares `tillGuards { read, shift, cash }`: booth =
`shift:read` / `shift:create` / `drawer:create` (unchanged), carwash = `carwash:read` /
`carwash:cash` / `carwash:cash`. The shift + drawer routes resolve the guard FROM THE TILL
(`requireTill(kind)`; `403 till_forbidden`), `/api/shift/tills` lists the tills a role may
read with a `canWork` flag, and history / movements without a till filter return the
union of the role's readable tills. So a wash role holds no `shift:*` at all and cannot
touch the booth by construction; the header button, the hub's start buttons and the
drawer switch never offer a till the server would refuse. (A first cut that borrowed
`session:read` as "works the booth till" lived for a few hours and is gone.)
- **The activity log is per till too (2026-09-06).** `tillOfEvent(type, payload)` in
`@parking/shared` extends the money rule to every event: a money event names its till, any
other event belongs to the till of the module that owns its type (a `carwash_order` is
wash-desk activity though no money moved), everything else — entries, exits, barrier
commands, pre-till events — is the booth's. `/api/events?till=` applies the same rule in
SQL (so the page limit applies after the filter); the hub's shift log, the Drawer "today"
panel and the booth feed (history and live pushes) pass their till. The events route also
admits a role without `event:read` that holds a module's feed permission, and then returns
only that module's event types — the live-socket rule, so a wash operator's hub shows the
wash shift's log.
- **The booth Z-report breaks module money out (2026-09-06).** `chargesByModuleMinor`
(`{ carwash: <minor> }`, only when any was taken) sums the `chargeLines` on the till's
payments by owning module; the ticket bucket EXCLUDES it, so `Bileta` is parking money only
and ticket + subscriptions + Σcharges = cash + card. Printed as `Lavazh (në biletë): X` on
the booth slip; the wash till's own slip prints its takings under `Lavazh:` (it sells no
tickets or subscriptions). Signed on `shift_z_report`; older reports read back as `{}`.
- **The wash till prints on its own printer (2026-09-06).** Printer role `wash-desk`; the
wash till's Z-report and vouchers go there, falling back to the booth printer — see
[[printer-roles-failover]].
## Where the fraud control actually lives
Deliberately **not** in a shift-close ceremony. Because every payment is a **signed event in the
append-only chain**, the printed cash figure *is* the system's tamper-evident truth. A manager
reconciles the signed Z-report against the actual drawer and the bank/POS batch **later** — that's
[[reconciliation]], the real control (deferred). The tradeoff vs. a heavier control is purely
*when* a skim is caught (after the fact, by a human), not *whether*.
> **Optional enhancement (not building now): blind cash count.** Have the operator enter the
> counted cash *before* the system reveals the expected figure, and record the variance into the
> `shift_z_report`. Blindness removes the operator's ability to back-fill their declaration to match
> expectation, catching a skim **at close** rather than later. Explicitly out of scope per
> 2026-06-15; documented as a clean add-on if ever wanted.
## Open
- **Drawer/shift reads fold the WHOLE chain — O(chain) growth (flagged 2026-07-05).**
`#drawerBalanceAt`, `currentOpenShift`, and `listShifts` select every ledger event and fold in JS.
Fine pre-opening; after months of operation this is a linearly growing pause on every shift open,
X-report, and drawer-balance read (the observed "opening a shift is slow"). Clean fix when it
bites: fold **from the last `shift_z_report` forward** — its `expectedDrawerMinor` is already the
signed balance snapshot at that point — instead of from genesis. Not built; the UI got busy
spinners in the meantime.
- **Drawer carry-over (decided 2026-06-18, built; vouchers re-modelled 2026-06-20; review reworked
2026-07-01):** opening float auto-inherits the prior shift's expected drawer; drawer movements are the
**`cash_in` / `cash_out` voucher pair** (Mandat Arkëtimi / Mandat Pagese — direction is the type),
superseding the signed-± `cash_movement` (kept for history). As of 2026-07-01 an operator RECORDS them
freely and an admin REVIEWS after (signed `cash_review`, a flag not a reversal) — see "Drawer review".
Z-report reports the full drawer picture. See the Drawer balance section above.
- **Shift ↔ session boundary:** a vehicle may enter under one shift and pay under another — the
Z-report sums by **payment time** (when cash/card was taken), which is the operator who handled
the money. Confirm that's the intended accountability (vs. by entry).
- **Mid-shift report / X-report — BUILT 2026-06-20.** On demand during the shift, the operator sees
the **opening float inherited**, **cash/card collected so far**, the **pay-ins/pay-outs**, and the
**current expected drawer balance** — without closing. `GET /api/shift/report` (`shift:read`, 204
when no shift is open) returns the SAME drawer projection the Z-report computes, factored into a
shared `ShiftService.#summariseWindow(open, asOf)` so X (asOf = now, read-only) and Z (asOf =
endedAt, signed) can never drift. **It appends NOTHING** — it's not an accountability mark (the
Z-report at close is the signed record). UI: a "Takings so far" button on the shift control opens a
cyan X-report panel; the header still shows the live drawer *total* for the at-a-glance number.
Verified against a copy of the live DB: matches `drawerBalance()`, drawer identity holds, 0 events
appended, chain still verifies.
- **Multiple lanes/booths** — whether a shift is per-operator, per-booth, or per-site
(relates to [[open-questions]] #1 lane topology).