The handheld and sled reference. Five scan modes, an offline queue that is a first-class screen rather than an error state, and two device profiles with materially different capabilities. Every event captured here is a unit hop written by the same transactional writer as everything else.
Reviewable in one glance with no navigation. Every touch target is at or above 44px and every motion degrades under prefers-reduced-motion.
| Profile | Form | Read | Write | Batt | Geiger | Reads/s | HB s | Offline | |
|---|---|---|---|---|---|---|---|---|---|
| fxr90 | fixed gate | yes | no | no | no | no | 700 | 60 | P1 |
| mc3390xr | handheld | yes | yes | no | yes | yes | 200 | 900 | P3 |
| rfd40 | sled | yes | yes | no | yes | yes | 150 | 1800 | P3 |
| zt411r | printer | no | yes | yes | no | no | 0 | 300 | P2 |
What each mode needs before it opens, what it emits, and whether it can run without a signal.
| Mode | Context required | Events emitted | Posting role | Offline safe | Approval | Blocks on |
|---|---|---|---|---|---|---|
| Cycle count | location + zone + an open count session | CYCLE_COUNT | observing | yes | yes, to post any variance | nothing, a count never blocks |
| Receive | a GRN with an accepted QC disposition | RECEIVE, ENCODE | originating | yes | no | unresolved short or excess lines |
| Dispatch verify | a dispatch manifest in packing or dispatched | DISPATCH | confirming | no, needs the live manifest | no | an unmatched tag on the dock |
| Put-away | units in state new or in_stock plus a bin | ZONE_CHANGE | observing | yes | no | bin label not scanned |
| Find a unit | an EPC or a serial code | none | observing | yes, from the cached index | no | nothing, read only |
| # | Rule | Enforced by |
|---|---|---|
| 1 | The idempotency key is minted on the device before the first send attempt and never changes across retries. | uq_mqi_idem |
| 2 | The server never generates a key. A server-side key would make two attempts two events. | API contract, 400 on a missing key |
| 3 | Items replay in client_seq order within a session. Out-of-order arrival is buffered, not applied. | idx_mqi_session |
| 4 | A replayed scan against a unit whose state has since moved is rejected as stale_state and surfaces for a decision. It is never force-applied. | R-12.41 |
| 5 | A rejected item is never silently dropped. It stays in the queue with a reason until an operator resolves it. | status='rejected' |
| 6 | Device clock skew is captured, not corrected. occurred_at uses the device clock; recorded_at uses the server clock. | device_clock_skew_ms |
| 7 | One open session per device and one per operator. A second device cannot capture into the same session. | two partial unique indexes |
| 8 | Backoff is 2s, 4s, 8s, 16s, then 30s steady. It never gives up while the session is open. | service worker |
| 9 | The queue is unbounded. A 41-minute outage at AJM must not start dropping the oldest scan. | IndexedDB, no cap |
| 10 | Undo posts a compensating hop within 120 seconds. After that the correction goes through the drift workbench. | R-12.42 |
| 11 | Closing a session with queued items is allowed, and the queue keeps draining. Closing does not discard. | mobile_sessions.ended_at |
| 12 | A scan from a device that is not commissioned lands with source_mode='sim' and posts nothing, however it arrived. | R-12.20 |
Warehouse gates and mall stockrooms have poor connectivity. If the offline queue is an error banner, the operator's mental model becomes "the app is broken" and they stop scanning. If it is a screen with a count, a per-item state and a retry button, the model becomes "the app is holding my work", and they keep going.
That is not a UX preference. A scan not taken is a unit i3 never sees, and the reconciliation reads it as shrinkage three hours later at 06:00 the next morning. Every abandoned scan becomes a false variance that a human then has to investigate.
The client-minted key is the whole mechanism. The device generates it once, at capture. The PWA can be force-quit, the iPad can reboot, the sync can be interrupted mid-batch and retried, and every one of those paths resolves to the same row. There is no window in which two attempts become two events.
The incumbent gets this wrong. The rfid-app passes an idempotency key on 5 of its 14 ledger write paths. A repeated receive POST on any of the other 9 credits the same stock twice. That is exactly the class of bug this design removes at source rather than patching per endpoint.