RestaurantBrain feasibility study

#R04 — Offline-First Architecture and the Order Domain Model

Scope: the counter. Order capture, kitchen routing, cash, multi-station consistency, and the sync substrate underneath. This is the part of a POS that cannot be bought, cannot be faked, and is where most POS startups quietly die.

Bottom line up front: the offline core is buildable by a small team, but only if you (a) treat the order as an event-sourced aggregate with a deterministic fold, not a mutable row; (b) refuse to let any managed sync product own conflict semantics; (c) accept that LAN multi-station sync is ~15 of the ~34 engineer-months and decide deliberately whether v1 needs it. Getting this wrong is not a bug backlog — it is a rewrite of the counter app plus a per-venue data migration.

Fact labels used throughout: [verified] = checked this session with a URL, [known] = confident from training data, [estimate] = reasoned guess.


#1. The hard requirement, stated precisely

"Works offline" is marketing. Engineers need a degradation matrix. Define four network states and what must still work in each.

StateWAN (internet)LANHub/serverMust still work
N0 Normalupupupeverything
N1 WAN downdownupuporder entry, kitchen print, cash, check close, receipt, multi-station sync, fiscal signing (local TSE only)
N2 Hub downupupdownorder entry, kitchen print, cash, check close, multi-station sync (degraded), no new global sequences
N3 Islanddowndowndownsingle device: order entry, kitchen print if printer is USB/BT-attached, cash, check close, receipt

#1.1 Capability degradation matrix

CapabilityN1 (WAN down)N3 (fully isolated device)Notes
Order entry, modifiers, coursesFullFullPure local state
Kitchen ticket print (LAN printer)FullBroken unless printer reachableThis is why N3 is a real product decision, not a theoretical one
Cash tender + drawer kickFullFullDrawer kicks off printer ESC/POS ESC p — needs printer reachable
Card paymentDepends on terminalDepends on terminalMost modern terminals (PAX A920 Pro, SumUp Solo, Adyen S1E2L) are themselves online-only for auth; store-and-forward is a PSP contract question, not ours
Loyalty lookup / guest profileCached snapshot onlyCached snapshot onlyCache last-90-days guests + top-N by frequency; ~5–50k rows per venue [estimate]
Gift card / stored value redeemDangerousDangerousShared balance across venues = double-spend. Recommend hard-block offline redemption above a floor limit
Fiscal receipt (DE TSE local)WorksWorks if TSE is USB/attachedSwissbit USB TSE attached to the hub is offline-capable; cloud-TSE (fiskaly) is not
Fiscal receipt (PL/UA/RO online regimes)Works in defined offline modeWorksEach regime has an explicit offline mode with catch-up rules — see §4.3
86'd item enforcementSoft (per-station allocation)SoftConsensus problem; oversell + alert beats refusing to sell
Reporting / back officeStaleStaleAcceptable

Design consequence: N3 (single device fully isolated, printer unreachable) cannot be made to print. Do not promise it. What you promise is: "the check is never lost, cash is always takeable, and the kitchen can be served with a handwritten chit if the printer is dead — and the POS tells the server that clearly, on the button they just pressed." Honest failure UX is a feature. Silent queueing that the server assumes printed is how you lose an account.


#2. Topologies

#2.1 Candidates

#TopologyHardwareSplit-brain riskVerdict
T0Single device ↔ cloud1 tabletNone (no peers)Correct for 1-station cafés/food trucks. ~35–50% of the SMB market [estimate]
T1LAN peer mesh, leaderlessN tabletsBenign for state, fatal for printing and sequencesNecessary substrate, insufficient alone
T2Elected device leaderN tabletsReal; needs epochs + fencingGood fallback, bad primary
T3Dedicated on-prem hubPi 5 / N100 mini-PC + UPSLow while hub is upBest primary
T4Cloud-only (no local peer path)N tabletsN/ADisqualified — violates N1

#2.2 Recommendation: T3 primary, T2 fallback, T1 substrate

State replication is T1 (every device holds the full event log for open checks — leaderless, converges by fold). On top of that, a single coordinator role exists for the three things that genuinely need one writer:

  1. Printer dispatch (exactly-once effects — §4.4)
  2. Sequence/number leasing (ticket numbers, check numbers)
  3. Payment terminal routing (which station owns which terminal right now)

The coordinator is the hub when the hub is alive; otherwise devices elect one. Correctness never depends on the coordinator being the hub — the hub is a convenience and a reliability upgrade, not a correctness requirement. If you make the hub required for correctness, every hub SD-card failure is a venue outage and a truck roll.

#2.3 Hub hardware

OptionBOMProsCons
Raspberry Pi 5 8GB + PoE+ HAT + NVMe HAT + 128GB NVMe + case~EUR 150–190 [estimate]PoE = one cable, no wall wart to unplug; NVMe removes the SD-card death modeARM64 build target; thermals in a hot kitchen cabinet
Intel N100 mini-PC (Beelink/GMKtec, 8/256)~EUR 130–180 [estimate]x86, runs anything, real NVMeWall wart (staff unplug it), no PoE, fanned
Repurposed Android tablet as "always-on hub"~EUR 0FreeAndroid background execution limits will kill your service. Do not.

Strong recommendation: Pi 5 + PoE, no SD card, ext4 on NVMe, read-mostly rootfs, all mutable state on a separate partition. PoE matters more than it sounds: the #1 cause of "the server died" in small venues is a human unplugging a power strip [estimate, from general field-support patterns]. PoE puts the hub's power on the same switch as the APs, and you can UPS the switch (~EUR 60–120 for a 600VA line-interactive unit) instead of UPS-ing a device in the ceiling.

#2.4 Discovery: mDNS, and why it will fail on you

Plan: advertise _rbpos._tcp.local with TXT records venue=<uuid> role=hub|peer epoch=<n> proto=3 devid=<uuid>.

Real-world failure modes you will hit:

FailureWhyMitigation
AP/client isolation on restaurant Wi-FiVenue's ISP router or guest-network config isolates clients; peers literally cannot route to each otherShip a network requirements doc + a validated AP (UniFi U6-Lite ~EUR 100 [estimate]); pre-flight check in the app that names the problem
Multicast blocked / IGMP snooping / "multicast enhancement" on UniFi & most managed APsmDNS is multicast; APs rate-limit or convert itNever rely on mDNS alone. Fallbacks in order: (1) last-known-IP cache persisted per device, (2) cloud rendezvous — devices publish their LAN IP to cloud when WAN is up, peers read it, (3) QR-code pairing that encodes hub IP, (4) /24 sweep on port 8443 as last resort
iOS local network permissionSince iOS 14, Bonjour/NWBrowser requires NSLocalNetworkUsageDescription + NSBonjourServices in Info.plist, and a user prompt [known]Prompt during onboarding with a screen that explains it; a denied permission must be detected and surfaced, not silently degrade
Android NsdManager flakinessWell-documented resolve failures, service-lost storms [known]Use jmDNS or a Rust/Go mDNS in the native layer; treat discovery as best-effort with aggressive re-announce (every 30s)
VLAN separation (POS VLAN vs printer VLAN)Sensible security, breaks discoverySupport explicit static printer/hub config; do not require discovery

Rule: discovery is an optimization. Every peer relationship must be expressible as an explicit, persisted (device_id, ip, port, cert_fingerprint) tuple that survives discovery being broken forever.

#2.5 Transport and trust on the LAN

  • Per-venue CA generated at venue provisioning (cloud-side), private key never leaves the cloud; each device gets a leaf cert with device_id in SAN, 90-day rotation while online, 1-year hard expiry.
  • Peer links: TLS 1.3 over TCP with mutual auth and pinned venue CA. WebSocket framing (easiest across RN/iOS/Android/desktop) or raw length-prefixed CBOR. QUIC is attractive (connection migration when a tablet roams APs) but adds platform pain on RN — [estimate] +1.5 eng-months for QUIC vs WS; defer.
  • Never trust "it's the LAN so it's safe." Restaurant Wi-Fi is shared with the guest network more often than anyone admits.

#2.6 Split-brain: what actually breaks

Partition the venue into groups A (2 tablets) and B (3 tablets + hub) for 20 minutes.

ConcernBehaviour under split-brainSeverity
Adding lines to different checksConverges cleanly on healBenign
Adding lines to the same check from both sidesConverges (union of lines)Benign — this is the whole point of event sourcing
Voids concurrent with editsConverges (void absorbs)Benign by design rule
Two coordinators electedBoth may dispatch prints, both may lease numbersSerious — see fencing below
Duplicate check numbersTwo "Check #241"Serious — fixed by number leasing, §4.3
Same card terminal driven by two stationsTerminal rejects the second (they're single-session)Annoying, self-limiting
Same payment recorded twiceOnly if payment_id is regeneratedFatal if you dedupe by amount instead of by id
Table double-seatedTwo servers open a check on table 12Annoying; resolve as two checks + a merge suggestion, never auto-merge

Fencing: every coordinator election increments a monotonic epoch stored durably on each participating device (not just the leader). Any message carrying epoch < max_seen_epoch is rejected. Printers and number leases are stamped with epoch. This is the standard fencing-token pattern and it is the difference between "two coordinators is confusing" and "two coordinators double-fires 40 tickets."

#2.7 Election and the "hub's battery dies mid-service" spec

Election: not Raft. A 2-device venue cannot form a quorum, and a POS must work with one device. Use a deterministic priority-bully scheme:

priority = (is_dedicated_hub ? 3 : 0)
         + (is_mains_powered  ? 1 : 0)
         + (battery_pct > 40  ? 1 : 0)
tiebreak = lowest device_uuid

Every peer heartbeats every 1s. Coordinator loss declared after 5 missed heartbeats (5s). Election window 2s. Total takeover budget: < 8 seconds, and during those 8 seconds order entry and cash still work (they're local); only printing and new number leases stall.

Timeline when the hub dies at 19:42 on a Friday:

tEventUser-visible
0sHub loses power (PoE switch on UPS → this is a hub crash, not a power cut)nothing
1–5sPeers miss heartbeatsnothing
5sCoordinator declared dead, epoch++nothing
5–7sElection: station "BAR-01" (mains-powered dock, lowest uuid) winssmall "Local server switching" toast
7sBAR-01 assumes coordinator role at epoch N+1; replays its own view of the print outboxnothing
7–9sPrint jobs in SENDING at the moment of failure enter verify-then-resend queue (§4.4)possible duplicate ticket marked *** REPRINT — VERIFY ***
9s+New checks lease numbers from BAR-01's pre-allocated block — no gap conflict because blocks were leased aheadnormal
Hub returnsHub sees epoch N+1 > its own N, does not reclaim coordinator automatically; becomes a peer, syncs, and takes over only at next service boundary or manual actionnothing

That last row matters: automatic failback causes a second failover storm. Failback is manual or scheduled.

The "battery dies" case specifically: if the coordinator is a tablet on battery (T2 fallback in a venue with no hub), add a pre-emptive handoff at battery < 20%: the coordinator voluntarily elects a successor and hands over the print outbox and number leases while it is still alive. This is a clean handoff, not a failover — no verify-then-resend, no duplicate tickets. Costs ~0.3 eng-months, removes the ugliest failure mode.


#3. Concurrency model for the order aggregate

#3.1 The aggregate

Check (id, venue_id, business_day, table_id?, tab_name?, party_size, opened_by, status)
 ├─ Seat        (seat_no, guest_id?)                        # seat-level splits
 ├─ Line        (line_id, sku, menu_version_id, qty,
 │               unit_price_minor, tax_code, tax_rate_bp,   # PRICE AT TIME OF ORDER
 │               seat_no?, course_no, state: ACTIVE|VOID|COMPED,
 │               fired_at?, print_job_id?)
 │    └─ Modifier (mod_id, sku, price_delta_minor)
 ├─ Discount    (discount_id, code, scope: CHECK|LINE, kind: PCT|AMT, value, authorised_by)
 ├─ Payment     (payment_id, tender, amount_minor, tip_minor, psp_ref?, state)
 └─ FiscalDoc   (fiscal_seq, signature, issued_at)          # allocated by fiscal device, never by us

Non-negotiable invariant: unit_price_minor, tax_rate_bp and menu_version_id are snapshotted onto the line at order time and never recomputed. A price change at 20:00 must not reprice a check opened at 19:30. This single rule kills an entire class of "the total changed while I was looking at it" bugs and is also a legal requirement in most consumer-protection regimes [known].

#3.2 Why naive last-writer-wins loses money — with numbers

Assume LWW at the document level (the classic Firestore/Realm-shaped mistake): each device writes the whole check object, last write wins.

ScenarioLWW outcomeMoney impact
Server A adds 2× Ribeye (EUR 62) at 19:31:04; Server B (bussing) adds 1× Water (EUR 3) at 19:31:06 from a stale readB's write wins → Ribeye vanishes; food is fired from a ticket that already printed−EUR 62 per incident
Manager voids a line at 19:40:02; Server changes qty on another line at 19:40:03 from stale readVoid lost → item stays on check, or (worse, if order reverses) qty change lost±EUR 10–40
Two devices apply the same 20% loyalty discountTwo Discount rows, both applied → 40% off (or 36% compounding)−20% of check
Cash EUR 20 taken on A; card for "remaining EUR 45" computed on B before A's payment landsCheck closes EUR 20 short, or guest is over-charged EUR 20±EUR 20, plus a chargeback
86'd item: last device to write "count" winsCounter drifts unboundedlyKitchen chaos, not money directly

At a venue doing EUR 4,000/day with 6 stations, [estimate] 0.5–2% of check value silently lost to document-LWW during busy periods. The restaurateur does not see it that day — they see it in the weekly P&L as an unexplained gap, and they will attribute it to staff theft before they attribute it to your software. That is how you lose accounts and get a reputation you can't argue with.

Even field-level LWW (Firestore-ish) doesn't save you: it converges collections badly (arrays), and it cannot express "void is absorbing" or "payments are additive, never overwritten."

#3.3 Three models compared

DimensionA. Single-writer + handoffB. CRDT (Automerge / Yjs)C. Event sourcing, per-device streams
Convergence guaranteeTrivial (one writer)Strong, provenStrong, if fold is deterministic + total order defined
Preserves business invariants across mergeYesNo — merging two valid docs can produce an invalid one (payments > total, void+edit)Yes — invariants are enforced in the fold, and violations become explicit states (over-tender, suppressed discount)
Works with 2 devices and no quorumNeeds a lock service → needs a leader → circularYesYes
"Server is in the walk-in fridge holding the lock"Fatal — needs lease expiry, which reintroduces conflictN/AN/A
Audit trail for tax authorityMust be built separatelyMust be built separately; tombstone GC fights auditabilityFree — the log is the journal (DE DSFinV-K wants exactly this [known])
Offline 3 weeks, then mergeLock long expired; undefinedConverges, semantics questionableConverges; stale-event policy is explicit and testable
Storage costLowAutomerge 3.0 cut steady-state memory ~10–100× via columnar in-memory representation (Moby-Dick-sized doc: 1.3 MB vs ~700 MB in v2) [verified] — no longer disqualifyingLow; compact on check close
Implementation cost2 eng-months (deceptively cheap, then you add leases, then epochs, then you've built C badly)3 eng-months to integrate + unbounded to bolt invariants on4–5 eng-months for model + fold + invariant suite
Debuggability at 21:00 on a FridayPoor (state only)Poor (opaque internal structure)Excellent — replay the exact log

#3.4 Recommendation: C — event sourcing with per-device append-only streams and a deterministic fold

Use A only as a UX affordance ("Anna is editing table 12" advisory badge, expires in 30s) and never as a correctness mechanism. Use B (Automerge or Yjs) for exactly one thing: free-text collaborative fields — order notes, kitchen notes, floor-plan editing in back office. Those are genuinely text-CRDT-shaped and worthless to hand-roll. Money is not.

The core of the design:

Event {
  event_id      UUIDv7          # client-generated; primary idempotency key
  device_id     UUID
  device_seq    u64             # strictly monotonic per device, gapless — detects loss
  aggregate_id  UUID            # check_id
  hlc           (u48 phys_ms, u16 counter, device_id)   # hybrid logical clock
  kind          enum
  payload       CBOR (unknown fields preserved)
  actor_id      UUID            # staff member; needed for audit + comps authority
  schema_v      u16
}
  • Per-device stream is totally ordered and gapless (device_seq). A receiver seeing device_seq jump from 41 to 43 knows event 42 exists and must request it before folding — this is how you detect silent loss without a full-log hash.
  • Cross-device order = sort by (hlc.phys, hlc.counter, device_id). Deterministic everywhere.
  • fold(events) → CheckState is a pure function. No clocks, no RNG, no I/O. This is what makes the whole thing testable (§6).

Command set (intent-based, all carrying a client-generated id):

OpenCheck, AddLine(line_id,…), ChangeQty(line_id, delta), VoidLine(line_id, reason, actor), CompLine(line_id, reason, actor), AddModifier(mod_id, line_id,…), SetSeat(line_id, seat_no), FireCourse(course_no), ApplyDiscount(discount_id, code,…), RecordPayment(payment_id, tender, amount_minor, tip_minor), MoveLine(line_id, from_check, to_check), SplitCheck(new_check_id, line_ids[]), MergeChecks(src, dst), TransferCheck(check_id, to_staff/to_table), CloseCheck, ReopenCheck(actor, reason).

Conflict semantics encoded in the fold (this table is the spec):

Concurrent pairRuleRationale
AddLineAddLineUnion. Distinct line_id ⇒ both existTwo servers adding food = two dishes. Never merge by SKU
VoidLine(L)ChangeQty(L)Void is absorbing — void wins regardless of HLCYou cannot un-void by editing. Absorbing element = commutative = order-independent
VoidLine(L)VoidLine(L)Idempotent on line_id; second recorded, no effect
VoidLine(L) after L is on a settled paymentRejected in fold → emits VoidRejected derived event, surfaced as a manager taskCannot silently reduce a settled check
ChangeQtyChangeQtyDeltas, not absolutes. Sum them, clamp at ≥0qty = 3 from two devices = 3. qty += 1 twice = 5. Deltas are what the human meant
ApplyDiscount(code=X)ApplyDiscount(code=X)Dedupe by code when policy is once_per_check; keep lowest HLC, record the other as suppressed (visible in audit)Prevents the 40%-off bug while keeping the audit trail
RecordPaymentRecordPaymentAdditive, never deduped by amount. Only payment_id dedupesTwo guests really do pay EUR 20 each
Payments sum > totalLegal state: OVER_TENDERED. UI shows "REFUND DUE EUR 12.40"Never drop money. Never auto-refund
MoveLineAddLine to source checkLine lands on source, then move applies. If move already applied, the new line stays on source and is flaggedExplicit repair beats guessing
SplitCheckSplitCheckBoth create children; duplicate children are detected on heal and surfaced as a merge taskDo not auto-merge financial containers
CloseCheck ∥ anythingEvents with hlc > close_hlc that would change totals are quarantined, not applied; manager sees "3 late changes to closed check #241"A closed check is fiscally sealed. Silent mutation is fraud-shaped
FireCourse(n)FireCourse(n)Idempotent on (check_id, course_no, fire_attempt); dedupe → one print jobThe double-fire guard is in the domain, and again in the print layer (§4.4)

86'd items: treat as a soft allocation, not a counter. Coordinator distributes remaining=10 as leases (e.g. 4/3/3 across three stations). A station sells against its lease without coordination. When a station exhausts its lease it requests more; if the coordinator is unreachable it allows the sale and flags it. Refusing to sell food you might have is worse than a kitchen phone call. Reconciliation on heal produces an Oversold(sku, by=2) event and a kitchen alert.

Seat-level splits: seat_no on the line is the primitive. "Split by seat" is a view, not a mutation, until the guest actually pays — then it becomes SplitCheck with explicit line_ids. Keeping split as a view for as long as possible removes 80% of the split/merge conflict surface [estimate].

Snapshotting: fold cost is O(events). A 3-hour table has [estimate] 40–120 events; trivial. Compact on CloseCheck + fiscal seal: write a frozen CheckSnapshot, keep events for 90 days on device / 7–10 years in cloud (tax retention: DE 10 years, PL 5 years, UA 3–7 years [known] — verify per market).


#4. Sync mechanics

#4.1 Outbox and idempotency

CREATE TABLE outbox (
  event_id     BLOB PRIMARY KEY,
  aggregate_id BLOB NOT NULL,
  device_seq   INTEGER NOT NULL,
  payload      BLOB NOT NULL,
  target       TEXT NOT NULL,        -- 'cloud' | 'peer:<device_id>'
  state        TEXT NOT NULL,        -- PENDING | INFLIGHT | ACKED
  attempts     INTEGER NOT NULL DEFAULT 0,
  next_attempt INTEGER NOT NULL      -- monotonic ms
);
CREATE INDEX outbox_due ON outbox(state, next_attempt);
  • Rows are written in the same SQLite transaction as the event append. No exceptions. An event that exists locally but not in the outbox is an event that never syncs.
  • Server ingest: INSERT … ON CONFLICT (event_id) DO NOTHING RETURNING server_seq. Idempotent by construction.
  • HTTP command endpoints (payments, PSP calls) additionally carry Idempotency-Key: <event_id> — every serious PSP supports this [known].
  • Backoff: 0.5s, 1s, 2s, 5s, 15s, 60s, then 60s forever with jitter. Never give up. A device offline 3 weeks must still drain its outbox.
  • Outbox rows are deleted only after cloud ack and peer ack (or peer marked permanently gone).

#4.2 Clock skew

  • Ordering never uses wall clock. HLC only. hlc.phys is nudged forward when a peer's HLC is ahead, clamped to +2 minutes/step to survive a device with a wildly wrong year.
  • Durations and timeouts use monotonic clocks: SystemClock.elapsedRealtime() (Android), mach_continuous_time() / CLOCK_MONOTONIC_RAW (iOS/Linux) [known]. A wall-clock-based retry timer breaks when NTP jumps.
  • Store both: occurred_at_wall (for humans, receipts, reports — may be wrong) and hlc (for ordering — always right). Reconcile occurred_at_wall server-side against ingest time and flag deltas > 5 min.
  • Business day is venue-configured (e.g. 04:00–04:00) and stamped by the device at event creation. A device with a wrong date will stamp the wrong business day; provide ReassignBusinessDay as an audited admin event rather than pretending it can't happen. A Z-report that silently swallows a mis-dated check is a tax problem.

#4.3 Numbering: three counters, never one

This is where teams conflate things and create a compliance incident.

CounterScopeGaps allowed?Who allocatesOffline behaviour
Kitchen ticket #per station/printerYes, freelyAny deviceLocal counter, cosmetic
Check / order #per venue per business dayYes (annoying, legal)Coordinator via leased blocksEach device pre-leases a block of 100; leases carry epoch; unused numbers are burned, producing gaps
Fiscal document #per fiscal device / per regimeUsually NO — must be gaplessThe fiscal component, never our appRegime-specific offline mode

The single most important structural decision in this document: never allocate a fiscal sequence number in application code. Delegate it to the certified fiscal component, which owns its own gapless sequence and has legally-defined offline behaviour:

RegimeFiscal componentOffline story
DE (KassenSichV/TSE)Swissbit USB/microSD TSE, Epson TSE, or cloud-TSE (fiskaly)TSE failure is expected and legal: the POS must record the failure (manufacturer, serial, receipt id, timestamp, failure number) tamper-proof, and receipts printed during failure must carry a note such as "Sicherungseinrichtung ausgefallen"; signing catches up when the TSE returns [verified]. ⇒ A local hardware TSE is offline-safe. A cloud-TSE is a WAN dependency and materially weakens the offline claim.
PL (KSeF + online cash registers)Certified online cash register; KSeF for invoicesKSeF has a permanent "Offline24" mode: issue offline, submit by the next business day; offline invoices carry two QR codes when delivered before KSeF submission. Cash-register invoices (paragony with NIP, simplified invoices ≤ PLN 450) remain valid through 31 Dec 2026. KSeF mandatory from 1 Feb 2026, with penalties deferred to 1 Jan 2027 [verified]
UA (PRRO)Software PRRO registered with DPSOffline mode with pre-issued blocks of offline fiscal numbers from the tax authority; limits on offline duration per outage and per month [known — verify current limits, they have changed]
ROANAF-approved fiscal printer (Datecs/Tremol/Custom)The printer owns the sequence and its own journal; POS drives it over serial/USB

UA's PRRO model is the pattern to copy internally: the authority hands you a lease of numbers to use offline. Design your check-number leasing identically — leases with epochs and expiry, gaps tolerated, duplicates impossible.

#4.4 Exactly-once effects on printers

Printers are the only place in the system with irreversible side effects. A duplicate database row is fixable; a duplicate ribeye is EUR 24 of food and a chef who stops trusting the tickets.

Cost of getting it wrong: [estimate] EUR 3–15 of food per duplicate ticket; a systemic reconnect-storm bug across 200 venues on one Friday = EUR 10–40k of comped food plus churn.

Architecture:

  1. One print coordinator per printer. Printing is the one place where single-writer is worth the complexity.
  2. Intent log before send, confirm after:
    fsync(job_id → SENDING, epoch, payload_hash)
    send to printer
    read status
    fsync(job_id → PRINTED | FAILED)
    On coordinator restart, all SENDING jobs go to a verify queue, not a resend queue.
  3. Prefer pull-based printers. Star CloudPRNT (mC-Print3, TSP143IV) has the printer poll an HTTP endpoint and POST back a delivery confirmation for a specific job id [known]. That confirmation is what makes exactly-once actually achievable. Epson ePOS-Print XML over LAN (TM-m30III, TM-T88VII) is push-based: you get an HTTP response with a status, but a lost response is genuinely ambiguous [known]. Epson's ePOS-Device / status APIs let you read printer state, which narrows but does not eliminate the ambiguity window.
  4. Never resend blind. For push printers, on ambiguity: mark the job AMBIGUOUS, print a new ticket header *** POSSIBLE REPRINT — job 7F3A — VERIFY WITH PASS ***. Humans are a valid part of an exactly-once protocol when the alternative is silence.
  5. Every ticket carries a short job id (4 hex chars) and a monotonic per-printer ticket number. Cooks learn to spot 7F3A twice.
  6. Kitchen Display Systems are strictly better than printers here — a KDS is a networked peer with state, so delivery is idempotent and confirmable. Where you can sell KDS instead of a printer, do; it removes the hardest correctness problem in the product. [estimate] a 15" Android KDS panel is EUR 250–450 vs EUR 280 for a TM-m30III — a genuinely easy upsell.

Transactional outbox for prints: the FireCourse event and the print job row are written in one SQLite transaction on the coordinator. If the coordinator changes mid-flight (§2.7), the incoming coordinator inherits the job table via the event log and resumes at the verify step.

#4.5 Replay, backfill, tombstones

MechanismDesign
ResumeDevice stores server_high_water_seq. GET /changes?since=<seq>&limit=1000 returns a totally-ordered server-assigned stream. Server seq is a single monotonic counter per venue (not global) — makes resume trivial and cheap
Backfill after 3 weeks offlineDo not replay 3 weeks of events. Server responds 410 SNAPSHOT_REQUIRED when since is older than the snapshot horizon (14 days [estimate]), device pulls a snapshot bundle (menu + staff + guest cache + open checks) then resumes from the snapshot's seq. Snapshot bundle target: < 20 MB gzipped for a typical venue [estimate]
TombstonesEvery deletable entity has deleted_at + deleted_by_event. Nothing is hard-deleted on device. Server issues purge_before_seq in the sync response; devices may then physically drop tombstones older than that. Without this, a device offline 3 weeks resurrects a deleted menu item and sells it at last quarter's price
Menu versioningMenu is immutable-versioned (menu_version_id). Devices hold the current version + the previous one. Lines reference the version they were priced from. A price change is a new version, never an update-in-place
GDPR erasure vs immutable logGuest PII lives in a separate, mutable store keyed by guest_id; the order event log stores only guest_id. Erasure = crypto-shred the PII record and tombstone guest_id. The financial log stays intact (it must — tax retention beats erasure for transaction records under GDPR Art. 17(3)(b) [known]). Design this on day one; retrofitting erasure into an immutable log is a 3-month nightmare

#4.6 Schema migration on a device that was offline 3 weeks

Four separate migration surfaces — teams typically remember one:

SurfaceRule
Local SQLite schemaForward-only, idempotent, versioned migrations that run before any sync and never require server data. Migration must be tested against a DB populated by the oldest supported app version, not an empty one
Event payload schemaCBOR with unknown-field preservation. A device on schema v7 that receives a v9 event must be able to re-transmit it byte-identically without corrupting it. Otherwise your old device becomes a lossy relay and silently drops the new service_charge_minor field
Fold semanticsTwo devices on different app versions must produce the same CheckState from the same log, or they diverge. ⇒ Fold changes are gated by a venue-wide fold_version that only advances when all devices have upgraded. New logic ships dark, activates on flag. This is the discipline most teams skip and it is where "the totals differ between the bar iPad and the manager's tablet" comes from
Server APIAccept N−2 event schema versions for ≥ 90 days. Never reject an event you don't understand — store it and let a newer consumer interpret it

Hard operational rule: a device more than K app-versions behind (or whose fiscal_ruleset_version is stale) may open checks and take orders, but may not tender payments or close checks. VAT rates change, service-charge rules change, fiscal formats change. Blocking money on a stale device is the cheap version of that problem; the expensive version is a tax reassessment.


#5. Storage / sync technology evaluation

TechModelOffline writeConflict modelLAN peer sync?Self-hostMoney-critical verdict
SQLite + bespoke sync (expo-sqlite/op-sqlite, Room, rusqlite)Local relational + your protocolFullYoursYes (you build it)N/AYes — the core. Costs 6–10 eng-months of sync you'd otherwise not write
PowerSyncPostgres/Mongo/MySQL/SQLServer → SQLite buckets; writes via upload queue to your backendFullServer-authoritative; you write the apply logicNo (device↔cloud)Yes, "Open Edition", source-available [verified]⚠️ Best managed candidate. Free 2 GB/50 conns; Pro from $49/mo; Team from $599/mo; Enterprise custom [verified powersync.com/pricing]. Its upload queue is an outbox — compatible with event sourcing. Deal-breaker: no LAN-only peer path, so it cannot satisfy N1 alone
ElectricSQLRead-path sync via "shapes" from PostgresRead-only sync; writes are entirely your problem, paired with TanStack DB [verified]N/A for writesNoYes (Apache-2.0)❌ Wrong shape. Good for read-heavy back-office dashboards
Zero (Rocicorp)Query-driven sync, custom mutators, server-authoritativeOptimistic + rebaseServer rebaseNoYes (Docker image published)⚠️ Hit 1.0 in June 2026 [verified — InfoQ]; excellent web DX. Reasonable for the back-office web app, not for the counter
ConvexReactive cloud backendWeak/noneN/ANoNo❌ No offline-first story, heavy vendor lock-in [known]
Firebase / FirestoreDoc store with offline persistenceYesField-level LWWNoNo❌ For money: no. LWW is exactly the failure in §3.2. Would ship a demo in 2 weeks; would cost you the rewrite
Realm / MongoDB Atlas Device SyncObject DB + managed syncFullField-level LWW + custom resolversNoPartiallyAnd a cautionary tale: MongoDB deprecated Atlas Device Sync with EOL around Sept 2025 [known — verify]. Teams that built POS on it were forced into an unplanned port. This is the concrete argument against betting the counter on managed sync
WatermelonDBSQLite + lazy loading, RN-first, sync protocol you implement server-sideFullRecord-level LWW by defaultNoN/A (MIT)⚠️ Fine for reference data (menu, staff, guest cache). Not for check state. Still actively used in 2026 [verified — weakly, via 2026 community posts]
RxDBReactive local DB + replication pluginsFullYoursNoPartly — some storage engines are RxDB Premium (paid) [known]⚠️ You still write the semantics; license friction for commercial redistribution
Automerge 3JSON CRDT (Rust core, WASM/C bindings)FullAutomatic, convergentYes (transport-agnostic)Yes (MIT)⚠️ Yes for one job: collaborative free-text notes and floor-plan editing. Memory objection is dead — 3.0 uses columnar representation at runtime, ~10–100× lower steady-state memory (Moby Dick: 1.3 MB vs ~700 MB in v2), file-format compatible with v2 [verified]. Still cannot express financial invariants
YjsText/shared-types CRDTFullAutomaticYesYesSame verdict as Automerge; stronger for rich text, weaker for structured docs

#5.1 Verdict

Build the counter on SQLite + a bespoke event-sync protocol. Own the conflict semantics.

Layer it:

LayerChoiceWhy
Order/money aggregateSQLite + event log + hand-written foldInvariants are the product
Device ↔ cloud transportPlain HTTPS + WebSocket, or PowerSync as pure transportSaves ~2–3 eng-months if you use PowerSync [estimate], but only if the event log remains the source of truth so you can rip it out
Device ↔ device (LAN)Bespoke, mTLS + CBOR framingNothing on the market does venue-LAN peer sync
Reference data (menu, staff, guests)Snapshot + version pull; WatermelonDB-style LWW acceptableServer-authoritative, no conflict
Free-text notes, floor planAutomerge 3Genuinely CRDT-shaped

#5.2 "Is it sane to trust a managed sync product with money-critical data?"

Trust it as a transport. Never as a semantics engine. Three concrete reasons:

  1. They define conflict resolution; you cannot afford theirs. Every managed product ships LWW or server-rebase defaults. Your §3.3 table is your product's actual value. Outsourcing it is outsourcing correctness.
  2. Vendor mortality. Atlas Device Sync deprecation [known] is a live example of a category leader exiting. Your POS must outlive any sync vendor by 10 years, because tax retention says so.
  3. The offline requirement they don't serve. Every managed product is device↔cloud. None does venue-LAN peer sync with the cloud unreachable. You are writing that regardless — at which point the managed product only saves you the easy half.

Mitigation if you do adopt one: keep the event log as the wire format so the sync vendor is carrying opaque blobs. Migration to a different transport then becomes a 2–3 week job instead of a rewrite.


#6. Test strategy

Standard test pyramids do not catch distributed money bugs. Budget for a simulator, not just a test suite.

#6.1 Deterministic simulation (the core investment)

Model the entire node — domain, sync, print dispatch, election — as a pure state machine over (input, injected time, injected network, seeded RNG). Nothing in the core touches Date.now(), Math.random(), sockets, or disk directly; all are injected. FoundationDB/TigerBeetle style.

Then: run 10⁵–10⁶ randomized scenarios per CI run. On failure, print the seed. The seed reproduces the bug exactly, forever, including on a laptop three years later.

[estimate] This costs ~3.5 eng-months and is the highest-ROI line item in the entire document. Teams that skip it debug distributed bugs by adding logs to production.

#6.2 Property-based invariants ("money in = money out")

Asserted after every event in every simulated scenario:

#Invariant
P1Σ(line.unit_price × qty for ACTIVE) + Σ(modifier deltas) − Σ(discounts) + Σ(tax) == check.total — in integer minor units, always
P2No line transitions VOID → ACTIVE or COMPED → ACTIVE without an explicit audited ReopenCheck
P3Σ(payments) ≤ check.total + max_tip or check.state == OVER_TENDERED with a surfaced refund amount
P4Convergence: fold(π(events)) == fold(events) for any permutation π that respects per-device device_seq order. This is the single most important test in the codebase
P5Every FireCourse maps to exactly one print job in {PRINTED, FAILED, AMBIGUOUS} — never zero, never two PRINTED
P6A CLOSED check's total is byte-identical before and after any further sync round
P7Z-report daily total == Σ closed-check totals == Σ fiscal document amounts, to the cent
P8device_seq is gapless per device across the whole log
P9No two distinct checks in one business day share a check number
P10Replaying the full log from empty reproduces the current snapshot bit-for-bit
P11Every discount and comp has an actor_id with sufficient authority at the time it was applied (authority is snapshotted, not looked up later)

All money in integer minor units. Never a float. Ever. [known] — this is the most common single bug in POS codebases.

#6.3 Network partition injection

  • Docker-compose lab with tc netem: latency 20/150/400 ms, loss 0/2/10%, reorder, duplication.
  • Partition shapes: clean 2-way split; asymmetric (A can send to B, B cannot send to A — the nastiest and the most realistic on flaky Wi-Fi); flapping (5s up / 5s down for 10 min); partial (3 of 5 nodes isolated); healing while a print job is in flight.
  • Durations: 30s, 5 min, 3 h, 3 weeks (simulated clock).
  • Kill/restart during partitions: SIGKILL mid-transaction, SIGKILL between the intent-log fsync and the printer send.

#6.4 Physical device lab

Simulation catches logic; only hardware catches "the iPad suspended the WebSocket in the background and Bonjour never came back."

ItemQtyUnit EUR [estimate]Total
iPad 10th/11th gen44001,600
Sunmi D3 Mini / T3 Android POS2450900
Elo I-Series 4 (Android, 15")1900900
Epson TM-m30III (LAN, ePOS-Print)1280280
Star TSP143IV + mC-Print3 (CloudPRNT)2275550
Epson TM-U220B impact (kitchen, grease-proof)1300300
Cash drawer + kick cable260120
UniFi U6-Lite AP + USW-Lite-8-PoE switch1260260
Raspberry Pi 5 hub kit (PoE, NVMe, case)2175350
Line-interactive UPS 600VA1100100
Payment terminals (SumUp Solo, PAX A920 Pro)2200400
Managed PDU / smart plugs for scripted power cuts1120120
Total (one rig)~EUR 5,900
Second identical rig for CI (recommended)~EUR 11,800 total

Scripted hardware chaos (the smart PDU earns its keep): cut printer power mid-job; cut hub power at a random point in a 4-hour soak; reboot the AP; pull a tablet off Wi-Fi onto LTE and back.

#6.5 Soak: the simulated Friday rush

Nightly, on the physical rig:

ParameterValue
Stations6 (4 iPad, 1 Sunmi, 1 Elo)
Duration4 h wall-clock
Covers120/h → ~1,400 orders, ~9,000 lines
Injected WAN outages3 × (2 min, 12 min, 45 min)
Injected hub failures1 hard power cut at a random t
Injected printer failures1 paper-out, 1 power cycle mid-job
Concurrent-edit rate8% of checks touched by ≥2 devices within 10s
Pass criteriaAll P1–P11 hold; p95 command→print < 2 s, p99 < 5 s; zero duplicate PRINTED; zero divergent CheckState across the 6 devices at end of run; battery drain < 45%/4h on iPad

#6.6 Production replay

Every venue's event log is uploadable on demand (pseudonymize guest PII in the replay pipeline — GDPR, and it makes the fixtures shareable internally). Support can replay a customer's actual Friday in the simulator and reproduce their complaint deterministically. This is a support superpower and it converts "the totals were wrong last Friday" from a 2-day archaeology exercise into a 20-minute investigation.

#6.7 Divergence detector (production)

Each device periodically publishes hash(CheckState) for every open check with its fold_version. The coordinator compares. Any mismatch fires an alert before the check closes. [estimate] 0.5 eng-months, and it is how you find out about a fold bug from telemetry instead of from an angry restaurateur.


#7. What it looks like when a team gets this wrong

Concrete, in the order it usually happens:

  1. Month 3 — "it works." Built on Firestore or Realm. Demos beautifully. One device, office Wi-Fi.
  2. Month 7 — first 3-station venue. Items intermittently disappear. Team adds "refresh" buttons and blames the network.
  3. Month 9 — the theft accusation. Owner's weekly P&L is short ~1.2%. He fires a server. Six weeks later he works out it's the POS. You will not get that account back, and he tells the other owners in his WhatsApp group.
  4. Month 10 — the double-fire Friday. A reconnect storm re-sends the print queue. Kitchen gets 40 duplicate tickets across 8 venues. Chefs start ignoring the printer and calling out orders verbally — your product is now decorative.
  5. Month 12 — the fiscal incident. App-allocated "fiscal" numbers gapped during an outage in PL or RO. Now it's an accountant's problem, then a tax authority's problem, and possibly a re-certification cycle ([estimate] 2–5 months and EUR 5–20k per regime).
  6. Month 13 — the diagnosis. The state model cannot express what the business needs. Fixing it means changing the write path, the merge path, the reports, and every deployed device's local schema simultaneously.
  7. Rebuild cost: [estimate] 9–18 engineer-months, plus a per-venue migration with a 4-hour maintenance window that no restaurant will grant you on any day they're open. In practice most teams ship "v2" as a parallel app and migrate venues one at a time over 6–9 months, while maintaining both.
  8. Meanwhile: a managed sync vendor deprecates (Atlas Device Sync [known]) and you port under time pressure with zero option value.

The asymmetry that should drive the decision: doing this right costs ~34 eng-months up front. Doing it wrong costs ~34 eng-months anyway, 12 months later, plus your first 50 customers, plus the founder's credibility in a market that runs on word of mouth.


#Build effort

Engineer-months for the offline/sync/domain core only. Excludes: UI/UX of the POS, menu management, back office, CRM/loyalty, payments PSP integration, per-country fiscal drivers, hardware certification matrix (all covered in sibling docs).

#Work itemEng-months [estimate]Variance drivers
1Event/command model, deterministic fold, conflict semantics table (§3.4), invariant enforcement4.03.0 if you cut split/merge/seat-splits from v1; 5.5 if you support course firing + coursing rules + service charges + tip pooling
2Local store: SQLite schema, forward-only migrations, SQLCipher, snapshot/compaction2.5+1.0 if you support desktop (Electron/Tauri) as well as iOS+Android
3Outbox + device↔cloud sync protocol (auth, resume, backfill, snapshot horizon, tombstones, purge)3.5−2.0 if you adopt PowerSync as transport; +1.0 for multi-venue/chain scoping
4LAN transport: mDNS + 4 discovery fallbacks, per-venue CA & cert provisioning, mTLS peer links, reconnect/roaming3.0+1.5 for QUIC; +1.0 if you must support VLAN-separated networks properly
5Coordinator role: election, epochs/fencing, number leasing, pre-emptive battery handoff, failback policy2.5+1.0 if you also elect for payment-terminal ownership arbitration
6On-prem hub: Pi image, A/B OTA update, remote diagnostics, disk/UPS handling, zero-touch provisioning2.5Drop entirely (−2.5) if you ship T2-only at v1; +1.5 if you add x86 build target
7Print pipeline: ticket rendering, ESC/POS + ePOS-Print XML + CloudPRNT drivers, exactly-once intent log, verify queue, reprint UX3.5+1.5 per additional printer family; −1.0 if you go KDS-first and treat printers as secondary
8Payments store-and-forward, offline tender rules, floor limits, terminal session ownership (excl. PSP integration)2.0+2.0 if any PSP requires certified offline auth flows
9Fiscal adapter abstraction (sequence delegation, failure recording, offline-mode state machine) — drivers costed separately1.5+0.5 per regime for the adapter shape alone
10Deterministic simulation harness + property suite P1–P11 + partition injection3.5Not compressible. Cutting this is the single worst decision available
11Device lab build-out, scripted hardware chaos (PDU), nightly soak automation, CI on real hardware2.0+1.0 if you want per-PR hardware runs rather than nightly
12Observability: sync lag, divergence detector, "checks that don't balance" alarm, per-venue health score2.0+1.0 for a customer-facing venue health dashboard
13Support/ops tooling: replay a venue, force resync, surgical audited event repair, snapshot export1.5+1.0 if support is non-engineering (needs a real UI, not a CLI)
Total for a trustworthy v1~34.0
Hardening after first 20 live venues (empirically unavoidable)+7–10

Reduced scopes worth costing:

ScopeEng-monthsWhat you give up
T0 only — single device per venue, cloud sync, no LAN peer, KDS-first~13Any venue with 2+ stations. Cuts items 4, 5, 6 and most of 3's complexity. Viable beachhead for cafés, bakeries, food trucks, single-counter QSR
T0 + T2 — LAN peer, elected device leader, no hub SKU~26Hub reliability, but nothing correctness-critical
Full T3~34

Calendar time:

ScenarioTeam on this domainCalendar [estimate]
S1 bootstrapped (founder + 2–4 eng, AI-assisted)~2.0 FTE realistically on the core (rest is UI/back office/CRM)16–20 months for full T3; 7–9 months for T0-only. AI assistance is worth maybe 0.7× on schema/driver/boilerplate work and ~1.0× on the genuinely distributed parts — the hard bugs are not in code you can autocomplete
S2 funded (15–30 people)4–5 FTE dedicated8–10 months for full T3, with better test infrastructure

S1 strategic read: a bootstrapped team should ship T0-only (≈13 eng-months) and pick a beachhead segment where single-station is genuinely sufficient, then earn the right to build T3. Attempting full multi-station offline sync as a first deliverable with 3 engineers is the most likely single cause of the project running out of runway before first revenue.


#Open questions / what would change this answer

  1. Does v1 need LAN multi-station at all? This is a ~15 eng-month swing (13 vs 34) and it is a market question, not an engineering one. What fraction of the CEE beachhead's addressable venues run 2+ POS stations? If it is under ~40%, ship T0 first. Would be settled by: 30 discovery calls with target venues counting actual station counts, plus competitor station-count data from Poster/SmartTouch/Syrve deployments.
  2. Is a cloud-TSE (fiskaly) legally acceptable for German offline operation, or does DE require a local hardware TSE for our offline claim? [verified] that TSE failure is legally accommodated with receipt marking and tamper-proof failure logging — but a cloud-TSE means every WAN outage is a TSE outage, which turns an exceptional path into the normal path. If DE is in scope, this determines whether we must ship Swissbit hardware TSEs with every venue (~EUR 60–150/unit [estimate]). Would be settled by: reading fiskaly's and Swissbit's compliance documentation plus one German Steuerberater consultation.
  3. Do we sell a hub SKU? EUR 150–190 BOM + a support/RMA burden + firmware OTA infrastructure, against materially better reliability and a simpler correctness story. Would be settled by: pricing the support cost of hub failures at ~2% annual hardware failure rate across N venues.
  4. Does PowerSync's Open Edition source-available license permit redistribution inside a commercial on-prem product? Cloud pricing is [verified] ($0 / $49 / $599 / custom), but source-available ≠ open source and on-prem redistribution is exactly the case such licenses restrict. Would be settled by: reading the actual LICENSE file in the powersync-service repo, 20 minutes of work.
  5. Will our target PSPs (Adyen, Nexi, SumUp, Viva Wallet, Ukrainian acquirers) permit store-and-forward card payments with a floor limit, and who bears the chargeback? This determines whether "take payments with the internet down" means "cash only" or "cash and cards." That is a large difference in the sales pitch. Would be settled by: written answers from 2 PSPs' integration teams.
  6. Confirm the Atlas Device Sync EOL date and check whether any managed sync vendor offers a contractual data-format escape hatch. [known] currently. If no vendor will contractually commit to an exportable, documented on-device format, that hardens the "own your sync protocol" recommendation from "strongly advised" to "mandatory." Would be settled by: MongoDB's deprecation notice + asking PowerSync/Zero for their format-stability commitments in writing.