# 0019. Hub sync and pairing

- Status: Accepted
- Date: 2026-10-27

## Context

In hub mode several POS devices share one shop: a waiter's phone opens a table that the counter
tablet charges, and every device sees held orders as they change. The hub keeps the canonical log
([ADR 0004](https://docs.openkedai.com/adr/0004-event-log-source-of-truth/)), but devices must keep selling when it is
unreachable ([ADR 0002](https://docs.openkedai.com/adr/0002-offline-first/)). A shop usually starts on one device in
single-device mode and adds a hub later, with no data loss. The hub may sit on a shop LAN with
no TLS, and before M7 there is no back office to show a pairing QR or to sign in to.

## Decision

**Adding a shop to a hub.** One hub can serve several separate shops (tenants), each in its own
SQLite file; a device's token ties it to one shop, so shops never see each other. The hub prints
a **setup code**, the hub owner's code for adding shops, in its log on every start
(`OPENKEDAI_SETUP_CODE` sets one). A shop's first device adds the shop by entering the hub's
address and the setup code ("Move this shop to a hub"). It uploads its outlet, catalog, staff
and devices, then its whole event log through the normal outbox. The hub's copy of the shop
takes the device's `tenantId` and `outletId`, so no event is rewritten. Several outlets of one
business in one shop come later; every row and event already carries its outlet.

**Pairing more devices.** An owner or manager on a paired device taps **Pair a device**. The hub
issues a one-time pairing code (8 characters, single use, valid for 10 minutes), shown as a QR
code with the hub's addresses and as text to type. The new device chooses **Join a hub** during
setup, scans or types the code, and gets a **device token** (32 random bytes; the hub stores only
its hash). A device that already has its own sales can't join another shop's hub: events can't
be rewritten into another tenant, and nothing is deleted.

**Device trust.** Every API call and the live connection carry the device token. A paired device
is trusted to act for its shop; the app decides which staff may do what, as in single-device
mode. Owner sign-in in a browser comes with the back office (M7).

**Events.**
- Devices push their own events from an outbox (events this device wrote after its push cursor)
  in gzip-compressed batches to `POST /api/events`. The hub validates them, rejects events for
  another tenant or written by another device, and stores them by id (`INSERT … ON CONFLICT DO
  NOTHING`), so a resent batch never duplicates.
- Devices pull every event of the shop after their hub cursor with `GET /api/events` and store
  them locally the same way. Each device replays orders and shifts with the same `packages/core`
  code, so all devices converge on the hub's state ([ADR 0007](https://docs.openkedai.com/adr/0007-authority/),
  [ADR 0015](https://docs.openkedai.com/adr/0015-event-ordering-and-conflicts/)).
- A WebSocket (`/api/live`) only says that something changed (new events up to a cursor, or a new
  catalog revision); devices then pull. Missed messages cost nothing: devices also pull on
  reconnect and after every push.

**Catalog, staff and settings** stay versioned rows, not events.
- A device pushes the rows it changed since its last push.
- The hub keeps an incoming row only if its `version` is higher than the hub's, and bumps the
  shop's catalog revision.
- Devices fetch the whole snapshot when the revision changes and keep a local row only while it
  is newer than the hub's, i.e. not yet pushed.
- When two devices change the same row offline, the first to reach the hub wins.
- Device rows sync only their code and name; receipt counters stay on the device.

**Discovery.** The hub advertises itself over mDNS as `_openkedai._tcp` (on by default,
`OPENKEDAI_MDNS=off` turns it off). The pairing QR carries every LAN address of the hub, so
devices don't need discovery to pair. (Since M8.5, devices also use it to follow a hub whose
address changed; see ADR 0022.)

**Versions.** `GET /api/hub` returns the API version. The app refuses to sync with a hub whose
major version differs and says which side to update.

## Consequences

- The hub keeps a small registry database (`<data>/hub.sqlite`): shops, device token hashes,
  pairing codes and the setup code.
- Anyone with the setup code can add a shop to the hub, so it is for the person who runs the
  server, not for shop staff. A public plugin route on a hub with several shops names the shop
  with `?shop=<tenant id>`.
- Plain HTTP on a LAN means anyone on the network can watch traffic. The guidance for remote
  access (HTTPS through Caddy, Cloudflare Tunnel or Tailscale) stays as in the brief.
- Catalog edits made offline on two devices can overwrite each other; sales never do.
- A stolen paired device can act for the shop until its token is revoked; revoking devices comes
  with the back office.
