#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.
| State | WAN (internet) | LAN | Hub/server | Must still work |
|---|---|---|---|---|
| N0 Normal | up | up | up | everything |
| N1 WAN down | down | up | up | order entry, kitchen print, cash, check close, receipt, multi-station sync, fiscal signing (local TSE only) |
| N2 Hub down | up | up | down | order entry, kitchen print, cash, check close, multi-station sync (degraded), no new global sequences |
| N3 Island | down | down | down | single device: order entry, kitchen print if printer is USB/BT-attached, cash, check close, receipt |
#1.1 Capability degradation matrix
| Capability | N1 (WAN down) | N3 (fully isolated device) | Notes |
|---|---|---|---|
| Order entry, modifiers, courses | Full | Full | Pure local state |
| Kitchen ticket print (LAN printer) | Full | Broken unless printer reachable | This is why N3 is a real product decision, not a theoretical one |
| Cash tender + drawer kick | Full | Full | Drawer kicks off printer ESC/POS ESC p — needs printer reachable |
| Card payment | Depends on terminal | Depends on terminal | Most 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 profile | Cached snapshot only | Cached snapshot only | Cache last-90-days guests + top-N by frequency; ~5–50k rows per venue [estimate] |
| Gift card / stored value redeem | Dangerous | Dangerous | Shared balance across venues = double-spend. Recommend hard-block offline redemption above a floor limit |
| Fiscal receipt (DE TSE local) | Works | Works if TSE is USB/attached | Swissbit 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 mode | Works | Each regime has an explicit offline mode with catch-up rules — see §4.3 |
| 86'd item enforcement | Soft (per-station allocation) | Soft | Consensus problem; oversell + alert beats refusing to sell |
| Reporting / back office | Stale | Stale | Acceptable |
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
| # | Topology | Hardware | Split-brain risk | Verdict |
|---|---|---|---|---|
| T0 | Single device ↔ cloud | 1 tablet | None (no peers) | Correct for 1-station cafés/food trucks. ~35–50% of the SMB market [estimate] |
| T1 | LAN peer mesh, leaderless | N tablets | Benign for state, fatal for printing and sequences | Necessary substrate, insufficient alone |
| T2 | Elected device leader | N tablets | Real; needs epochs + fencing | Good fallback, bad primary |
| T3 | Dedicated on-prem hub | Pi 5 / N100 mini-PC + UPS | Low while hub is up | Best primary |
| T4 | Cloud-only (no local peer path) | N tablets | N/A | Disqualified — 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:
- Printer dispatch (exactly-once effects — §4.4)
- Sequence/number leasing (ticket numbers, check numbers)
- 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
| Option | BOM | Pros | Cons |
|---|---|---|---|
| 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 mode | ARM64 build target; thermals in a hot kitchen cabinet |
| Intel N100 mini-PC (Beelink/GMKtec, 8/256) | ~EUR 130–180 [estimate] | x86, runs anything, real NVMe | Wall wart (staff unplug it), no PoE, fanned |
| Repurposed Android tablet as "always-on hub" | ~EUR 0 | Free | Android 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:
| Failure | Why | Mitigation |
|---|---|---|
| AP/client isolation on restaurant Wi-Fi | Venue's ISP router or guest-network config isolates clients; peers literally cannot route to each other | Ship 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 APs | mDNS is multicast; APs rate-limit or convert it | Never 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 permission | Since 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 flakiness | Well-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 discovery | Support 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_idin 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.
| Concern | Behaviour under split-brain | Severity |
|---|---|---|
| Adding lines to different checks | Converges cleanly on heal | Benign |
| Adding lines to the same check from both sides | Converges (union of lines) | Benign — this is the whole point of event sourcing |
| Voids concurrent with edits | Converges (void absorbs) | Benign by design rule |
| Two coordinators elected | Both may dispatch prints, both may lease numbers | Serious — see fencing below |
| Duplicate check numbers | Two "Check #241" | Serious — fixed by number leasing, §4.3 |
| Same card terminal driven by two stations | Terminal rejects the second (they're single-session) | Annoying, self-limiting |
| Same payment recorded twice | Only if payment_id is regenerated | Fatal if you dedupe by amount instead of by id |
| Table double-seated | Two servers open a check on table 12 | Annoying; 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_uuidEvery 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:
| t | Event | User-visible |
|---|---|---|
| 0s | Hub loses power (PoE switch on UPS → this is a hub crash, not a power cut) | nothing |
| 1–5s | Peers miss heartbeats | nothing |
| 5s | Coordinator declared dead, epoch++ | nothing |
| 5–7s | Election: station "BAR-01" (mains-powered dock, lowest uuid) wins | small "Local server switching" toast |
| 7s | BAR-01 assumes coordinator role at epoch N+1; replays its own view of the print outbox | nothing |
| 7–9s | Print 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 ahead | normal |
| Hub returns | Hub 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 action | nothing |
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 usNon-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.
| Scenario | LWW outcome | Money 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 read | B'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 read | Void lost → item stays on check, or (worse, if order reverses) qty change lost | ±EUR 10–40 |
| Two devices apply the same 20% loyalty discount | Two 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 lands | Check closes EUR 20 short, or guest is over-charged EUR 20 | ±EUR 20, plus a chargeback |
| 86'd item: last device to write "count" wins | Counter drifts unboundedly | Kitchen 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
| Dimension | A. Single-writer + handoff | B. CRDT (Automerge / Yjs) | C. Event sourcing, per-device streams |
|---|---|---|---|
| Convergence guarantee | Trivial (one writer) | Strong, proven | Strong, if fold is deterministic + total order defined |
| Preserves business invariants across merge | Yes | No — 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 quorum | Needs a lock service → needs a leader → circular | Yes | Yes |
| "Server is in the walk-in fridge holding the lock" | Fatal — needs lease expiry, which reintroduces conflict | N/A | N/A |
| Audit trail for tax authority | Must be built separately | Must be built separately; tombstone GC fights auditability | Free — the log is the journal (DE DSFinV-K wants exactly this [known]) |
| Offline 3 weeks, then merge | Lock long expired; undefined | Converges, semantics questionable | Converges; stale-event policy is explicit and testable |
| Storage cost | Low | Automerge 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 disqualifying | Low; compact on check close |
| Implementation cost | 2 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 on | 4–5 eng-months for model + fold + invariant suite |
| Debuggability at 21:00 on a Friday | Poor (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 seeingdevice_seqjump 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) → CheckStateis 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 pair | Rule | Rationale |
|---|---|---|
AddLine ∥ AddLine | Union. Distinct line_id ⇒ both exist | Two servers adding food = two dishes. Never merge by SKU |
VoidLine(L) ∥ ChangeQty(L) | Void is absorbing — void wins regardless of HLC | You 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 payment | Rejected in fold → emits VoidRejected derived event, surfaced as a manager task | Cannot silently reduce a settled check |
ChangeQty ∥ ChangeQty | Deltas, not absolutes. Sum them, clamp at ≥0 | qty = 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 |
RecordPayment ∥ RecordPayment | Additive, never deduped by amount. Only payment_id dedupes | Two guests really do pay EUR 20 each |
| Payments sum > total | Legal state: OVER_TENDERED. UI shows "REFUND DUE EUR 12.40" | Never drop money. Never auto-refund |
MoveLine ∥ AddLine to source check | Line lands on source, then move applies. If move already applied, the new line stays on source and is flagged | Explicit repair beats guessing |
SplitCheck ∥ SplitCheck | Both create children; duplicate children are detected on heal and surfaced as a merge task | Do not auto-merge financial containers |
CloseCheck ∥ anything | Events 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 job | The 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.physis 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) andhlc(for ordering — always right). Reconcileoccurred_at_wallserver-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
ReassignBusinessDayas 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.
| Counter | Scope | Gaps allowed? | Who allocates | Offline behaviour |
|---|---|---|---|---|
| Kitchen ticket # | per station/printer | Yes, freely | Any device | Local counter, cosmetic |
| Check / order # | per venue per business day | Yes (annoying, legal) | Coordinator via leased blocks | Each device pre-leases a block of 100; leases carry epoch; unused numbers are burned, producing gaps |
| Fiscal document # | per fiscal device / per regime | Usually NO — must be gapless | The fiscal component, never our app | Regime-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:
| Regime | Fiscal component | Offline 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 invoices | KSeF 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 DPS | Offline 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] |
| RO | ANAF-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:
- One print coordinator per printer. Printing is the one place where single-writer is worth the complexity.
- Intent log before send, confirm after:
On coordinator restart, allfsync(job_id → SENDING, epoch, payload_hash) send to printer read status fsync(job_id → PRINTED | FAILED)SENDINGjobs go to a verify queue, not a resend queue. - 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'sePOS-Device/ status APIs let you read printer state, which narrows but does not eliminate the ambiguity window. - 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. - Every ticket carries a short job id (4 hex chars) and a monotonic per-printer ticket number. Cooks learn to spot
7F3Atwice. - 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
| Mechanism | Design |
|---|---|
| Resume | Device 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 offline | Do 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] |
| Tombstones | Every 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 versioning | Menu 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 log | Guest 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:
| Surface | Rule |
|---|---|
| Local SQLite schema | Forward-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 schema | CBOR 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 semantics | Two 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 API | Accept 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
| Tech | Model | Offline write | Conflict model | LAN peer sync? | Self-host | Money-critical verdict |
|---|---|---|---|---|---|---|
SQLite + bespoke sync (expo-sqlite/op-sqlite, Room, rusqlite) | Local relational + your protocol | Full | Yours | Yes (you build it) | N/A | ✅ Yes — the core. Costs 6–10 eng-months of sync you'd otherwise not write |
| PowerSync | Postgres/Mongo/MySQL/SQLServer → SQLite buckets; writes via upload queue to your backend | Full | Server-authoritative; you write the apply logic | No (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 |
| ElectricSQL | Read-path sync via "shapes" from Postgres | Read-only sync; writes are entirely your problem, paired with TanStack DB [verified] | N/A for writes | No | Yes (Apache-2.0) | ❌ Wrong shape. Good for read-heavy back-office dashboards |
| Zero (Rocicorp) | Query-driven sync, custom mutators, server-authoritative | Optimistic + rebase | Server rebase | No | Yes (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 |
| Convex | Reactive cloud backend | Weak/none | N/A | No | No | ❌ No offline-first story, heavy vendor lock-in [known] |
| Firebase / Firestore | Doc store with offline persistence | Yes | Field-level LWW | No | No | ❌ 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 Sync | Object DB + managed sync | Full | Field-level LWW + custom resolvers | No | Partially | ❌ And 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 |
| WatermelonDB | SQLite + lazy loading, RN-first, sync protocol you implement server-side | Full | Record-level LWW by default | No | N/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] |
| RxDB | Reactive local DB + replication plugins | Full | Yours | No | Partly — some storage engines are RxDB Premium (paid) [known] | ⚠️ You still write the semantics; license friction for commercial redistribution |
| Automerge 3 | JSON CRDT (Rust core, WASM/C bindings) | Full | Automatic, convergent | Yes (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 |
| Yjs | Text/shared-types CRDT | Full | Automatic | Yes | Yes | Same 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:
| Layer | Choice | Why |
|---|---|---|
| Order/money aggregate | SQLite + event log + hand-written fold | Invariants are the product |
| Device ↔ cloud transport | Plain HTTPS + WebSocket, or PowerSync as pure transport | Saves ~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 framing | Nothing on the market does venue-LAN peer sync |
| Reference data (menu, staff, guests) | Snapshot + version pull; WatermelonDB-style LWW acceptable | Server-authoritative, no conflict |
| Free-text notes, floor plan | Automerge 3 | Genuinely 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:
- 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.
- 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. - 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 |
| P2 | No 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 |
| P4 | Convergence: fold(π(events)) == fold(events) for any permutation π that respects per-device device_seq order. This is the single most important test in the codebase |
| P5 | Every FireCourse maps to exactly one print job in {PRINTED, FAILED, AMBIGUOUS} — never zero, never two PRINTED |
| P6 | A CLOSED check's total is byte-identical before and after any further sync round |
| P7 | Z-report daily total == Σ closed-check totals == Σ fiscal document amounts, to the cent |
| P8 | device_seq is gapless per device across the whole log |
| P9 | No two distinct checks in one business day share a check number |
| P10 | Replaying the full log from empty reproduces the current snapshot bit-for-bit |
| P11 | Every 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:
SIGKILLmid-transaction,SIGKILLbetween 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."
| Item | Qty | Unit EUR [estimate] | Total |
|---|---|---|---|
| iPad 10th/11th gen | 4 | 400 | 1,600 |
| Sunmi D3 Mini / T3 Android POS | 2 | 450 | 900 |
| Elo I-Series 4 (Android, 15") | 1 | 900 | 900 |
| Epson TM-m30III (LAN, ePOS-Print) | 1 | 280 | 280 |
| Star TSP143IV + mC-Print3 (CloudPRNT) | 2 | 275 | 550 |
| Epson TM-U220B impact (kitchen, grease-proof) | 1 | 300 | 300 |
| Cash drawer + kick cable | 2 | 60 | 120 |
| UniFi U6-Lite AP + USW-Lite-8-PoE switch | 1 | 260 | 260 |
| Raspberry Pi 5 hub kit (PoE, NVMe, case) | 2 | 175 | 350 |
| Line-interactive UPS 600VA | 1 | 100 | 100 |
| Payment terminals (SumUp Solo, PAX A920 Pro) | 2 | 200 | 400 |
| Managed PDU / smart plugs for scripted power cuts | 1 | 120 | 120 |
| 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:
| Parameter | Value |
|---|---|
| Stations | 6 (4 iPad, 1 Sunmi, 1 Elo) |
| Duration | 4 h wall-clock |
| Covers | 120/h → ~1,400 orders, ~9,000 lines |
| Injected WAN outages | 3 × (2 min, 12 min, 45 min) |
| Injected hub failures | 1 hard power cut at a random t |
| Injected printer failures | 1 paper-out, 1 power cycle mid-job |
| Concurrent-edit rate | 8% of checks touched by ≥2 devices within 10s |
| Pass criteria | All 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:
- Month 3 — "it works." Built on Firestore or Realm. Demos beautifully. One device, office Wi-Fi.
- Month 7 — first 3-station venue. Items intermittently disappear. Team adds "refresh" buttons and blames the network.
- 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.
- 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.
- 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). - 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.
- 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. - 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 item | Eng-months [estimate] | Variance drivers |
|---|---|---|---|
| 1 | Event/command model, deterministic fold, conflict semantics table (§3.4), invariant enforcement | 4.0 | 3.0 if you cut split/merge/seat-splits from v1; 5.5 if you support course firing + coursing rules + service charges + tip pooling |
| 2 | Local store: SQLite schema, forward-only migrations, SQLCipher, snapshot/compaction | 2.5 | +1.0 if you support desktop (Electron/Tauri) as well as iOS+Android |
| 3 | Outbox + 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 |
| 4 | LAN transport: mDNS + 4 discovery fallbacks, per-venue CA & cert provisioning, mTLS peer links, reconnect/roaming | 3.0 | +1.5 for QUIC; +1.0 if you must support VLAN-separated networks properly |
| 5 | Coordinator role: election, epochs/fencing, number leasing, pre-emptive battery handoff, failback policy | 2.5 | +1.0 if you also elect for payment-terminal ownership arbitration |
| 6 | On-prem hub: Pi image, A/B OTA update, remote diagnostics, disk/UPS handling, zero-touch provisioning | 2.5 | Drop entirely (−2.5) if you ship T2-only at v1; +1.5 if you add x86 build target |
| 7 | Print pipeline: ticket rendering, ESC/POS + ePOS-Print XML + CloudPRNT drivers, exactly-once intent log, verify queue, reprint UX | 3.5 | +1.5 per additional printer family; −1.0 if you go KDS-first and treat printers as secondary |
| 8 | Payments 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 |
| 9 | Fiscal adapter abstraction (sequence delegation, failure recording, offline-mode state machine) — drivers costed separately | 1.5 | +0.5 per regime for the adapter shape alone |
| 10 | Deterministic simulation harness + property suite P1–P11 + partition injection | 3.5 | Not compressible. Cutting this is the single worst decision available |
| 11 | Device lab build-out, scripted hardware chaos (PDU), nightly soak automation, CI on real hardware | 2.0 | +1.0 if you want per-PR hardware runs rather than nightly |
| 12 | Observability: sync lag, divergence detector, "checks that don't balance" alarm, per-venue health score | 2.0 | +1.0 for a customer-facing venue health dashboard |
| 13 | Support/ops tooling: replay a venue, force resync, surgical audited event repair, snapshot export | 1.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:
| Scope | Eng-months | What you give up |
|---|---|---|
| T0 only — single device per venue, cloud sync, no LAN peer, KDS-first | ~13 | Any 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 | ~26 | Hub reliability, but nothing correctness-critical |
| Full T3 | ~34 | — |
Calendar time:
| Scenario | Team on this domain | Calendar [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 dedicated | 8–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
- 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.
- 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. - 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.
- 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. - 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.
- 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.