#System architecture
TL;DR
- The Check is an append-only event log per Device with a pure deterministic fold — not a mutable row. Everything else (offline, audit, fiscal correction, replay-a-Friday support) falls out of that one decision. Document-level or field-level last-writer-wins silently loses 0.5–2% of check value at a multi-writer Site
[estimate], and the operator blames staff theft before he blames us. The writer model, the numbering model, the tenancy key order and the PII split are schema-level: changing them after 20 live Sites costs 9–18 eng-months[estimate]. - One money engine, written once in Kotlin, compiled to JVM for the backend and to Android for the counter. This resolves the only direct contradiction in the research inputs (r05 wanted TypeScript everywhere, r08 wanted Kotlin at the counter). A second implementation is a permanent penny-mismatch bug farm; a TS runtime inside the Kotlin app re-imports the ~5.5 eng-month bridging tax the Kotlin decision exists to avoid
[estimate]. - v1 is T0: one Station per Site, counter-service, Android-only. The Coordinator interface, the
epochfencing token and the conflict table all ship in MVP; the LAN peer transport does not. That is the difference between a 17–20 eng-month offline core and a 48–58 one[estimate]— see 07 — Roadmap, Team and Budget. - Three number spaces, never one: cosmetic Ticket numbers (any Device), Check numbers (Coordinator-leased blocks, gaps legal), Fiscal Document numbers (allocated only by the certified fiscal component, never by our code). Business Day is leased, never stamped from a device wall clock.
- Printers are the only irreversible side effect in the system. Intent-log → send → verify, every job stamped with the Coordinator
epoch, never a blind resend. A reconnect storm that double-fires across 200 Sites on one Friday is EUR 10–40k of comped food plus churn[estimate].
Scope of this document: how the system is put together and why. Hardware SKUs and printer/peripheral behaviour live in 03 — Hardware, peripherals and the device fleet; PSP, PCI and fiscal obligations in 04 — Payments, fiscalisation, privacy and security; CRM feature scope in 05 — The CRM Product and Scope Tiers; third-party connectors in 06 — Integrations; effort, sequencing and money in 07 — Roadmap, Team and Budget; the failure modes we accept in 08 — Pros, cons, difficulties and risks.
#1. The stack, stated exactly
| Layer | CHOSEN | Reversal trigger |
|---|---|---|
| Counter clients (POS, KDS, expo, CFD) | Kotlin 2.x + Jetpack Compose, Android-only, min SDK 26, laid out as Compose Multiplatform modules :domain :data :ui-core :ui-pos :android-app with an inert :ios-app seam | Beachhead moves to US/Western EU with >40% iPad attach, or 2 senior Kotlin/Android engineers cannot be hired in 8 weeks → React Native (New Architecture, bridgeless) |
| Shared money engine | One Kotlin :domain module, pure, no I/O, integer minor units, no bare dates, compiled JVM + Android | Rust → WASM with FFI both sides, +3.5–5.0 eng-months [estimate]. A TypeScript engine inside the Kotlin app is rejected |
| Backend services | Kotlin/JVM, Ktor, modular monolith, OpenAPI-first REST, generated clients (Ktor 3.x [known]) | TypeScript/Node 22 + Fastify — only if the shared-engine decision reverses |
| Web clients | TypeScript + React + Vite, desktop-first 1280px+ | — |
| Guest surfaces (v2 core) | TypeScript + React with SSR (Next.js), one multi-tenant PWA on the restaurant's own domain + Apple/Google Wallet passes | — . Per-restaurant white-label native apps prohibited (Apple Guideline 4.2.6 [known]) |
| Local device store | SQLite via SQLDelight + SQLCipher, append-only event log + transactional outbox in the same transaction | — |
| Device ↔ cloud sync | Bespoke: HTTPS + WebSocket carrying our own event envelopes, server-assigned per-Site monotonic seq, resume/backfill/snapshot horizon | PowerSync as opaque transport (FSL, converted to Apache-2.0 since 2026-05-31 [verified]), saves ~2 eng-months, only if the event log stays the wire format |
| Device ↔ device (LAN) | Not in v1. T0 single-Station only. T2 in v2 core: mTLS 1.3 + CBOR framing, elected Coordinator, durable monotonic epochs as fencing tokens | — . T3 dedicated hub SKU is cancelled permanently |
| Conflict semantics | Event sourcing, per-Device append-only streams (UUIDv7 event_id, gapless device_seq, HLC ordering), pure deterministic fold | CRDT rejected for money (Automerge permitted only for free-text notes). Field-level LWW disqualified |
| Primary database | PostgreSQL 17, shared schema, org_id as leading column of every composite PK and index, RLS as backstop, order/order_line/order_event RANGE-partitioned monthly on business_date | DB-per-tenant and schema-per-tenant rejected |
| Analytics | Read replica + materialised views to 200 Sites, then self-hosted ClickHouse fed from the domain-event outbox (never CDC) | ClickHouse Cloud / BigQuery rejected on cost and Schrems II exposure |
| Realtime | Own WebSocket gateway, one connection per Device, per-Site monotonic seq with bounded gap replay, Postgres LISTEN/NOTIFY fanout to 200 Sites | NATS JetStream — deferred to 200 Sites |
| Jobs / workflows | Postgres transactional outbox + SELECT … FOR UPDATE SKIP LOCKED; four long-running flows as explicit state machines | Temporal / Restate / DBOS — revisit above 8 long-running flows |
| Card-present payments | Stripe Terminal, semi-integrated, server-driven + local SDK; Stripe Reader S700 and S710 [verified]. Zero EMV L2/L3 work, ever | Adyen Terminal API (the hedge, and the only confirmed source of PAR [verified]) |
| Fiscal signing | Buy. FiscalProvider interface owned by us; CZ needs none; UA PRRO via Checkbox/Vchasno; PL via a GUM-homologated kasa-online printer partner | Direct national implementations, ~18–22 eng-months for DE+AT+FR+IT+ES+PT [estimate] |
| PCI posture | Semi-integrated only, zero PAN, ever. Merchants land on SAQ B-IP; we run SAQ D-Service Provider | — . "SAQ P2PE" is struck from every document and sales asset |
| Printing | Raster-only (HTML/Canvas → 1-bit bitmap → GS v 0), raw TCP 9100 from the native Android app, Star CloudPRNT pull for kitchen printers where the SKU allows | Epson Server Direct Print. A browser print path is rejected: no browser opens a TCP socket, and Chrome Local Network Access (Chrome 142+ [verified]) has no WebView UI on Android |
| Fleet / MDM | Base-tier MDM (Hexnode or Mosyle, ~EUR 5.5/Site/mo) for enrolment + kiosk lock-task + OS update window only; remote config, flags, telemetry, log capture, printer and device health in our own in-app agent | Esper Bridge (USD 4/device/mo) above 200 Sites or with a kiosk fleet |
| Infrastructure | Hetzner, one EU cell (Falkenstein/Nuremberg), self-hosted. Capacity planning is horizontal only — the CCX line repriced +113–176% on 15 June 2026 and any rescale reprices [verified] | AWS eu-central-1, only when a signed enterprise contract funds the second cell |
| Observability | Self-hosted GlitchTip + Grafana/Loki/Prometheus from day one | Sentry Business |
| Flags / config | OpenFeature + self-hosted Unleash, evaluated synchronously offline from a signed, monotonically-versioned config bundle the device refuses to downgrade, LKG cache, 7-day TTL that warns rather than fails | — . LaunchDarkly per-MAU pricing rejected |
| AI | Anthropic API through one internal ai-gateway; nothing else imports the SDK. claude-opus-5 for extraction and NL-analytics tool-calling, claude-sonnet-5 for narrative, claude-haiku-4-5 for classification, Batch API for nightly | — . Text-to-SQL over the open schema is prohibited |
| Billing | Stripe Billing | Chargebee |
Languages the company maintains: Kotlin and TypeScript. Nothing else, unless a Tauri site bridge is ever forced by a fiscal printer (not in CZ).
#2. Context and component map
Trust boundaries. The Device authenticates with a device-bound Keystore key plus a per-Device certificate that the cloud can revoke on next connect. The card never touches us — PAN lives inside the Stripe Reader, we hold only payment_intent_id, the acquirer transaction reference and a nullable par. Guest PII lives in a separate crypto-shreddable store; the financial event log holds guest_id only.
#2.1 Component map (backend modular monolith)
Boundaries are enforced by package visibility and an ArchUnit-style test, not by convention. :domain imports nothing. fiscal is a separately-versioned, slow-release module because certificates bind a named software version — it does not ride the fast app's release train.
#3. Offline-first design
#3.1 Network states and what must keep working
| State | WAN | LAN | Coordinator | Must work in v1 (T0) |
|---|---|---|---|---|
| N0 normal | up | up | up | everything |
| N1 WAN down | down | up | up (the till itself) | order entry, kitchen print, cash, Check close, Receipt, loyalty against cached Guest set, Voucher redemption (signed, offline-verifiable) |
| N2 Coordinator down | up | up | down | v1: not reachable — the till is the Coordinator. v2 core/T2: order entry, print, cash; no new leases |
| N3 island (printer unreachable) | down | down | — | order entry, cash, Check close. Kitchen print is impossible and the UI says so on the button the cashier just pressed |
The sales claim is exactly: "orders, kitchen, cash and receipts keep working with no internet; card payments depend on your acquirer." Store-and-forward offline card auth is never built. See 04 — Payments, fiscalisation, privacy and security.
#3.2 Writer model for the Check aggregate
Every Device owns one append-only stream. There is no shared mutable row anywhere in the write path.
Event {
event_id UUIDv7 // client-generated, the primary idempotency key
org_id UUID // leading key everywhere, including on the wire
site_id UUID
device_id UUID
device_seq u64 // strictly monotonic per Device, gapless — detects silent loss
aggregate_id UUID // check_id
hlc (u48 phys_ms, u16 counter, device_id)
kind enum
payload CBOR // unknown fields preserved byte-identically
actor_id UUID // Employee; required for audit and Comp authority
schema_v u16
}- Total order across Devices = sort by
(hlc.phys, hlc.counter, device_id). Deterministic on every platform. fold(events) → CheckStateis pure. No clock, no RNG, no I/O. This is what makes the property suite possible and what makes production replay a support tool rather than an aspiration.- Gap detection is free: a receiver seeing
device_seqjump 41 → 43 knows event 42 exists and refuses to fold past it until it arrives. - Snapshot on close. A 3-hour Check is 40–120 events
[estimate]; fold cost is irrelevant. OnCloseCheck+ fiscal seal we write a frozenCheckSnapshot. On-device retention is 7 days of closed Checks plus all open Checks, hard alarm at 500 MB — a busy Site produces 100–170k events/day[estimate]and 90 days on a 2 GB RK3568 makes month 9 slow at your best customer.
Commands, all carrying a client-generated id: OpenCheck, AddLine, ChangeQty(delta), VoidLine, CompLine, AddOption, ApplyDiscount, RecordPayment, FireTicket, CloseCheck, ReopenCheck. Table-service commands (SetSeat, SplitCheck, MergeChecks, TransferCheck, FireCourse) are v3 — but their conflict rules are written into the fold in MVP, because retro-fitting commutativity into a shipped fold means re-deriving the whole table.
#3.3 Conflict rules — this table is the spec
| Concurrent pair | Rule | Why |
|---|---|---|
AddLine ∥ AddLine | Union; distinct line_id ⇒ both exist | Two staff adding food = two dishes. Never merge by SKU |
VoidLine(L) ∥ ChangeQty(L) | Void is absorbing — wins regardless of HLC | Absorbing ⇒ commutative ⇒ order-independent |
VoidLine(L) ∥ VoidLine(L) | Idempotent on line_id | — |
VoidLine(L) after L is on a settled Payment | Rejected in fold → VoidRejected derived event → manager task | Cannot silently reduce a settled Check |
ChangeQty ∥ ChangeQty | Deltas, never absolutes. Sum, clamp ≥0 | qty=3 twice is 3; qty+=1 twice is 5. Deltas are what the human meant |
ApplyDiscount(X) ∥ ApplyDiscount(X) | Dedupe by code when policy is once_per_check; keep lowest HLC, record the other suppressed | Prevents the 40%-off bug, keeps the audit trail |
RecordPayment ∥ RecordPayment | Additive. Dedupe only by payment_id, never by amount | Two Guests really do pay EUR 20 each |
| Σ Payments > total | Legal state OVER_TENDERED, UI shows "REFUND DUE EUR 12.40" | Never drop money, never auto-refund |
CloseCheck ∥ anything | Events with hlc > close_hlc that change totals are quarantined, surfaced as "3 late changes to Check #241" | A closed Check is fiscally sealed; silent mutation is fraud-shaped |
FireTicket(n) ∥ FireTicket(n) | Idempotent on (check_id, station_id, fire_attempt) | Double-fire guard lives in the domain and again in the print layer |
| 86 / availability | Soft allocation with leases, not a counter. Exhausted lease + unreachable Coordinator ⇒ allow the sale, flag it, emit Oversold(sku, by=n) | Refusing to sell food you might have is worse than a phone call to the kitchen |
Two-writer convergence, drawn. In v1/T0 the two writers are the till and a cloud-side action (a manager voiding from back office while the till is offline). In v2 core/T2 they are two Stations on the LAN (two waiters on handhelds only once v3 ships). Same fold, same rules — which is why the rules ship now.
Property P4 — fold(π(events)) == fold(events) for every permutation π respecting per-Device device_seq — is the single most important test in the codebase. It is written by a human before the fold exists, and the fold is accepted only by the property suite, never by review. An AI-written fold that iterates a hash map passes P1–P3 and violates P4 [known, observed].
#3.4 Sync protocol and the idempotency ladder
| Concern | Mechanism |
|---|---|
| Local durability | Event append and outbox row in the same SQLite transaction. An event that exists locally but not in the outbox never syncs |
| Upstream idempotency | INSERT … ON CONFLICT (org_id, event_id) DO NOTHING RETURNING server_seq |
| PSP / external calls | Idempotency-Key: {check_id, attempt_seq} on every payment_attempt; the acquirer reference is persisted forever |
| Backoff | 0.5s, 1s, 2s, 5s, 15s, 60s, then 60s with jitter, forever. A Device offline 3 weeks must still drain |
| Resume | Device stores server_high_water_seq; GET /changes?since=<seq>&limit=1000, server seq is monotonic per Site, not global |
| Backfill | Server answers 410 SNAPSHOT_REQUIRED when since predates the snapshot horizon (14 days [estimate]); Device pulls a bundle (Menu Snapshot + staff + Guest cache + open Checks), target <20 MB gzipped [estimate], then resumes |
| Tombstones | Everything deletable has deleted_at + deleted_by_event; nothing hard-deletes on Device; server issues purge_before_seq. Without this a Device offline 3 weeks resurrects a deleted Item and sells it at last quarter's price |
| Erasure | GDPR tombstones replicate over the same channel with per-Device ack tracking and per-Device revocable data keys |
| Schema skew | Event payloads are CBOR with unknown-field preservation — a v7 Device re-transmits a v9 event byte-identically. Server accepts N−2 for ≥90 days and never rejects an event it does not understand |
| Fold skew | fold_version advances only when all non-evicted Devices have upgraded, with explicit eviction plus 14-day auto-evict surfaced in back office ("TILL-02 hasn't synced in 30 days — retire it?"). Without eviction one lost tablet pins a Site to the old fold forever |
| Stale-Device money block | A Device more than K versions behind, or with a stale fiscal_ruleset_version, may open Checks and take orders but may not tender or close |
#3.5 Numbering, Business Day, and the clock
| Counter | Scope | Gaps? | Allocator | Offline behaviour |
|---|---|---|---|---|
| Ticket number | per Station | free | any Device | local counter, cosmetic, printed on paper as the dedupe primitive of last resort |
| Check number | per Site per Business Day | legal, annoying | Coordinator, leased blocks of 100 | Device pre-leases ahead; leases carry epoch; unused numbers burn |
| Fiscal Document number | per fiscal regime | usually never | the certified fiscal component, never our code | regime-defined. CZ needs none; UA PRRO uses authority-issued offline blocks |
Business Day is leased from the Coordinator, never stamped from a device wall clock. A cheap Android AIO with a dead RTC cell boots with a wrong date; every Check that shift lands in the wrong Daily Close and — in a signing market — on the wrong fiscal day. Rule: |device_wall − coordinator_hlc_phys| > 5 min ⇒ the Device may take orders but may not close or tender, and the fiscal adapter refuses to sign outside its trusted-time threshold. Ordering never uses wall clock (HLC only); durations use SystemClock.elapsedRealtime(); every event carries both occurred_at_wall (for humans and Receipts) and hlc (for ordering), and the server flags deltas >5 min.
#3.6 Exactly-once at the printer
Printers are the only irreversible effect. A duplicate row is fixable; a duplicate ribeye is EUR 3–15 of food [estimate] and a chef who stops trusting the tickets — after which the product is decorative.
Rules that make the diagram true: one print Coordinator per printer; epoch is derived from (persisted_epoch, boot_id, monotonic_start) so a lost fsync after a whole-Site power cut cannot resurrect a duplicate epoch; a claim needs majority ack from reachable peers or cloud confirmation, and in a two-Device venue with no WAN the election loser freezes print dispatch for 60 s; kitchen printers prefer CloudPRNT pull, where the printer acks a specific job id and genuine at-least-once-with-dedupe is achievable; failback is manual or at a service boundary, never automatic; a station failover prints backlogged tickets in fire order with a cap and a "print all / print summary?" prompt, not in queue-drain order. fsync honesty on the certified SKUs is tested in week 3 with a smart PDU at 1,000 random power-cut offsets — not in month 14.
Terminal ownership uses the same fencing epoch: a second collectPaymentMethod arriving at a reader that already has a card presented can cancel the first after the tap and before our result, leaving an authorisation at the PSP with no local Payment. Nightly reconciliation pulls PSP transactions by idempotency key and surfaces orphans. This is base scope.
#4. Canonical data model
Terminology is binding: Customer pays us, Guest eats, Check is the financial container, Ticket is a kitchen instruction, Order is an inbound external order (v2 core onwards). Banned synonyms do not appear in schema, API or UI strings.
| Entity | Key fields | Notes |
|---|---|---|
org | org_id PK, legal_name, country, cell, billing_ref | Leading column of every other table's PK |
brand | (org_id, brand_id), default_locale, tax_class_set | Menu namespace. Price never lives here |
site | (org_id, site_id), brand_id, tz, business_day_start (default 04:00), currency, fiscal_profile | One physical restaurant |
site_group | (org_id, group_id) + membership | Free-form many-to-many. Price lists and publishes target groups, never enumerated Site lists |
device | (org_id, device_id), serial, model, station_role ∈ TILL/KDS/EXPO/CFD/HANDHELD, cert_fingerprint, app_ver, fold_version, last_seen | Role is assigned from the Site plan at provisioning |
employee | (org_id, employee_id), name, per-Site role grants, pin_argon2id, pin_salt | PIN derived at ~250 ms on the slowest certified SKU, wrapped with the device-bound Keystore key, scoped to managers at that Site |
check | (org_id, check_id), site_id, business_date, check_number, state, opened_by, total_minor, fold_version, close_hlc | Projection of check_event; never written directly |
check_event | (org_id, event_id), device_id, device_seq, hlc, kind, payload CBOR, actor_id, schema_v, server_seq | The source of truth. RANGE-partitioned monthly on business_date |
line | (org_id, line_id), check_id, variant_id, qty, unit_price_minor, tax_rate_bp, menu_snapshot_id, state ∈ ACTIVE/VOID/COMPED, station_id, ticket_id | The three bold fields are snapshotted at order time and never recomputed |
line_option | (org_id, line_id, option_id), quantity, price_applied_minor, free_units_applied | An Option belongs to an Option Group. Size is a Variant, never an Option |
discount | (org_id, discount_id), check_id, scope CHECK/LINE, kind PCT/AMT, value, code, authorised_by, suppressed | Reduces the taxable base pro rata across tax classes |
payment | (org_id, payment_id), check_id, tender, amount_minor, tip_minor, state ∈ pending/approved/declined/unknown, idempotency_key, psp_ref, acquirer_txn_ref, par NULL | unknown is never rendered as "failed"; SCA soft-declines are in-flight, not terminal |
fiscal_document | (org_id, fiscal_doc_id), check_id, provider, fiscal_seq, signature, issued_at | Numbers allocated only by the certified component |
daily_close | (org_id, site_id, business_date), seq, prev_hash, hash, tender/tax/category sums, cash expected vs counted | Immutable, hash-chained, per Site per Business Day |
cash_drawer_session | (org_id, session_id), site_id, station_id, employee_id, opening_float, counted, expected, variance | Every drawer open logs actor + reason |
menu_snapshot | (org_id, snapshot_id), content_hash, brand_id, site_group, channel_set, locale_set, artefact_ref | Immutable, content-hashed, atomically swapped by the Device |
item / item_variant / option_group / option | catalog graph, tax_class_id, attrs jsonb, min/max/free_count/free_policy | Draft graph; the Snapshot is what ships |
price_list / price | channels[], site_group, priority, valid_from/to, recurrence jsonb, amount_minor | Absolute prices only, never percentage uplifts at read time |
guest (PII store) | (org_id, guest_id), name, per-Guest data key | Separate, crypto-shreddable store. The financial log holds guest_id only |
identity | (org_id, identity_id), kind ∈ phone_e164/email/loyalty_id/par_hash, value_hash, source | Deterministic matching only until 500 tenants |
cluster | (org_id, cluster_id), primary_identity, version | The derived set of Identities believed to be one Guest |
loyalty_ledger | (org_id, entry_id), member_id, delta_points, reason, prev_hash, hash | Append-only, hash-chained |
voucher | vid UUIDv7, member_id, face_value, expiry, Ed25519/COSE token, spent_at | Signed, single-use, offline-redeemable |
consent_record | (org_id, identity_id, channel, purpose), policy_text_hash, ui_surface, granted_at | Append-only. ui_surface has no pos_staff value and the API rejects it |
#4.1 Architecture invariants (non-negotiable)
| # | Invariant |
|---|---|
| 1 | All money is integer minor units. Never a float, in any language, anywhere |
| 2 | unit_price_minor, tax_rate_bp, menu_snapshot_id are snapshotted onto the Line and never recomputed |
| 3 | Fiscal Document numbers come only from the certified fiscal component; Check numbers from Coordinator-leased blocks; Ticket numbers are cosmetic |
| 4 | Business Day is leased; >5 min clock skew blocks close and tender |
| 5 | Every lease and every print job carries a durable monotonic epoch |
| 6 | Terminal ownership is a lease with the same fencing epoch, plus nightly PSP reconciliation by idempotency key |
| 7 | payment_attempt has an explicit unknown state; idempotency key {check_id, attempt_seq}; SCA soft-declines classified in-flight and filtered before reconciliation matching |
| 8 | The acquirer transaction reference is persisted forever; referenced refunds are mandatory |
| 9 | Guest PII in a crypto-shreddable store; erasure tombstones replicate with per-Device ack; rendered ticket bitmaps never persist beyond a 24 h retry window |
| 10 | consent_record.ui_surface has no pos_staff value; the cashier-consent button does not exist and cannot be added |
| 11 | The migration importer sets marketing suppression by provenance; the segment builder cannot clear it |
| 12 | Fiscal logic lives in a slow-release, separately-versioned module |
| 13 | The property suite is written by a human before the fold exists |
| 14 | Release freeze Thu 12:00 → Mon 10:00; cohorts lab → 3 friendly Sites → 10% → 40% → 100%, ≥24 h soak spanning a dinner service; Android rollback does not exist, kill switches are the only mitigation |
| 15 | On-device retention: 7 days of closed Checks + all open Checks, hard alarm at 500 MB |
| 16 | No query outside an explicit transaction (lint-enforced), or SET LOCAL app.org_id and RLS are theatre under PgBouncer transaction pooling |
| 17 | Offline manager PINs are Argon2id (~250 ms on the slowest certified SKU), per-(org, employee) salt, Keystore-wrapped, scoped to managers at that Site |
#5. Menu and pricing engine
The Menu Snapshot is an immutable, content-hashed, compiled artefact. Editors mutate a draft graph; publish validates, then compiles one denormalised document per
(brand, site_group, channel_set, locale_set)with Option trees, price lists, tax rules, availability, layout and image manifest fully resolved. Devices download by content hash and swap atomically. The Device performs zero resolution logic.
| Concern | Design |
|---|---|
| Variants vs Options | Size is a Variant, never an Option. Otherwise per-size recipes, per-size SKUs and per-channel per-size pricing are unfixable later |
| Nesting | Option Groups nest to depth ≤3, validated at publish. Deeper is unreviewable and destroys the touch UI |
| Free counts | free_count + free_policy ∈ cheapest / most_expensive / first_selected, applied to units, not Options — 2× extra cheese consumes 2 free slots |
| Price lists | (channels[], site_group, priority, valid_from/to, recurrence). Happy hour is a price list with a recurrence. Absolute amounts only; the "+20% and round to .49" action materialises real rows |
| Time-varying prices | The one thing the Device resolves: all applicable lists ship inside the Snapshot and the Device selects by now() in Site tz — but the Coordinator broadcasts an authoritative price-list epoch on the heartbeat, so a wrong device clock cannot fiscalise a wrong happy-hour price |
| Tax | tax_rule(jurisdiction, tax_class, channel, rate_bp, inclusive, effective_from). CZ v1: 12% reduced / 21% standard, inclusive, CZK 1 cash rounding on the cash tender only. Rounding scope and mode are jurisdiction properties, not constants |
| Discounts | Reduce the taxable base proportionally across tax classes |
| Service Charge vs Tip | Service Charge is VAT-able revenue modelled as a distinct entity; Tip is outside VAT scope. They are never interchangeable, in schema or in UI |
| Allergens | Held on the ingredient, derived through the recipe DAG, with a mandatory manual override layer (EU FIC 1169/2011 [known]) |
| Language | translation(entity_type, entity_id, locale, field, value) with fallback site.locale → brand.default_locale → en. One Check renders in ≥2 locales at once — Ticket in the kitchen's language, Receipt in the Guest's. v1 locales: cs, uk, en |
| Size / cost | A 400-Item menu with 300 Options compiles to ~1.5–4 MB JSON, 300–700 KB gzipped [estimate]; ~50 ms per Site to compile [estimate]. Publish is a parallel fan-out |
| Safety | Golden-snapshot corpus + property tests in CI: reordering Option selection never changes the price; Σ(line totals) + tax + service − discounts == check total in minor units |
A POS screen layout is a separate versioned artefact from the catalog, and a layout must survive its referenced Item being 86'd, unpriced or deleted — the cell renders disabled with a reason. This is the single most common crash class in POS software [known].
#6. Realtime, KDS delivery, and liveness
| Concern | Design |
|---|---|
| Transport | WebSocket, one connection per Device, device-token auth, subscribed to site:{id} and station:{id}. Not SSE — KDS acks tickets and the server pushes commands (reload config, force sync, open drawer) |
| Ordering | Every message carries a monotonic per-Site seq. On reconnect the Device sends its last seq and the gateway replays the gap from a bounded buffer. Without this you get silent Ticket loss on Wi-Fi blips, which is the #1 way a KDS product is thrown out |
| Fanout | Postgres LISTEN/NOTIFY to 200 Sites, then NATS JetStream. Not before — a message bus on the on-call rota for a workload you will not have inside the runway is a cost, not an architecture |
| Liveness | The KDS renders last-heartbeat age in a corner and changes the entire screen background after 45 s without a Coordinator heartbeat; the Coordinator alarms when a Station holding open Tickets stops acking. A socket in CLOSE_WAIT looks alive to the app and dead to the kitchen |
| Non-GMS devices | Do not buy them. A non-GMS SKU costs an own MQTT foreground service plus a WorkManager watchdog plus an AlarmManager heartbeat — 1.8–2.2 eng-months [estimate] — because OEM battery managers kill long-lived services on exactly the cheap AOSP ROMs. See 03 — Hardware, peripherals and the device fleet |
| Latency budget | finger-down → visual ack ≤50 ms; tap → Line on the ticket ≤100 ms; "send to kitchen" → confirmation ≤200 ms (persist + enqueue locally, never wait on network or printer); cold start ≤1.5 s on a 2 GB RK3568; p99 frame ≤16.7 ms [estimate, all from the soak criteria] |
| Election suppression | Suppress Coordinator elections for 120 s after a link-down affecting all peers simultaneously — if you lost everyone, you lost the network, not the Coordinator. Nightly ISP CPE reboots at 03:00–04:00 otherwise produce a nightly failover storm inside the OS update window |
#7. Analytics pipeline
device events → ws-gateway → api (ingest, projections) → outbox (same txn)
→ worker publishes versioned DOMAIN EVENTS: check_closed, payment_captured,
daily_close_written, menu_published, voucher_redeemed, campaign_sent
→ [≤200 Sites] read replica + materialised views, 5–15 min refresh
→ [>200 Sites] self-hosted ClickHouse, batched 5 s, AggregatingMergeTree pre-aggregatesDomain events, never CDC. Debezium-style logical replication couples the analytics schema to the OLTP schema and replays every column rename as an outage. The outbox already exists for sync; analytics is a second consumer of it.
Everything user-facing reads a metric layer: named, parameterised, tenant-scoped SQL functions with a typed (dimensions, filters, period) signature. The 10 canonical reports are calls into it, not 10 hand-written endpoints — and the same layer is the only surface the NL-analytics agent may touch. Daily Close is the reconciliation anchor: P7 — Daily Close total == Σ closed Check totals == Σ Fiscal Document amounts, to the cent — asserted in CI and monitored in production.
#8. The AI layer and where it plugs in
One ai-gateway. Nothing else in the codebase imports the Anthropic SDK. It owns model routing, prompt versioning + eval harness, request/response logging with PII scrubbing, per-tenant caps, per-feature global spend caps that require a human to raise them, and a global kill switch.
| Plug point | Model | Shape | Guardrail |
|---|---|---|---|
| Menu ingestion (PDF/photo → draft catalog) | claude-opus-5 | Document + image blocks, citations on, JSON-Schema output, plus a second numeric cross-check pass | Never auto-publishes. Every field shows its source crop; low-confidence prices block publish until a human touches them. Realistic accuracy 85–95% of fields [estimate]; the win is 40 h → 3–4 h, not 40 h → 0 |
| POS-migration import (Dotypos, CSV/Excel) | claude-opus-5 | Same pipeline | Same review UI. This is the switching-cost killer |
| Menu-engineering narrative | claude-sonnet-5 | SQL does the classification; the model writes the paragraph | Numbers never come from the model |
| NL analytics | claude-opus-5 | Tool-calling over the fixed metric layer. get_sales, compare_periods, get_pmix, get_labor | Text-to-SQL over the open schema is prohibited. Tenant scope is enforced in the tool implementation, never in the prompt. Every answer links to the underlying report |
| Guest replies, campaign copy, review replies | claude-sonnet-5 | Draft only | Never auto-send below 4★ |
| Classification, ingredient matching | claude-haiku-4-5 | — | — |
| AI tier-0 support agent | claude-sonnet-5 | Reads Device telemetry, files the ticket before the restaurant calls | Cannot change tenant data |
| Anything nightly | Batch API (50% off [known]) | — | — |
Cost levers in order: prompt caching (Menu Snapshot and metric catalogue as a stable prefix; reads ~0.1×, writes 1.25× [known]; minimum cacheable prefix 512 tokens on claude-opus-5 [verified]), Batch API, effort tuning. Steady state EUR 0.30–1.50/Site/month plus EUR 0.50–3.00 one-off per onboarding [estimate]. Menu ingestion is ~USD 0.53/menu at Opus rates for a ~6-page menu [estimate on token counts, model prices verified] against EUR 200–1,200 of human menu-build labour. AI cost is not a risk; AI correctness is. There is no EU inference pin on the first-party Anthropic API today (us or global only) [known] — mitigation is aggregates and pseudonymous IDs only, DPA + SCCs, and a per-tenant AI kill switch.
#9. Environments, CI, release
| Environment | Contents | Purpose |
|---|---|---|
dev | ephemeral per-branch Postgres, seeded Site, emulated Devices | Feature work |
lab | physical rig: 2 Sunmi D3, 1 Elo KDS, TM-m30III, TM-U220II, Star TSP143IV, drawer, RUT241, UniFi AP, UPS, smart PDU for scripted power cuts | The only place device truth exists. Capex EUR 8–12k rig 1, second CI rig EUR 8–10k by M12 |
staging | production-shaped single cell, synthetic Friday-rush load | Release candidate soak |
prod | one EU cell, Hetzner Falkenstein/Nuremberg, horizontal scaling only | — |
CI stages, in order, all blocking: compile → :domain property suite (P1–P11, 10⁵ randomised scenarios per run, seed printed on failure) → golden Menu Snapshot corpus → golden CZ Receipt renderings (byte-diffed) → 500-case promo-rule corpus → tenancy lint (no query outside a transaction, no raw SQL without org_id) → API contract diff → deutan/protan screenshot check on KDS → nightly on-rig soak (4 h, 6 Stations, 3 injected WAN outages, 1 hard power cut, 1 paper-out; pass = all invariants hold, zero duplicate PRINTED, zero divergent CheckState).
Release. Signed build pipeline → cohorts lab → 3 friendly Sites → 10% → 40% → 100%, minimum 24 h soak spanning a dinner service at each step, never advancing Fri/Sat. Freeze Thursday 12:00 → Monday 10:00. Android has no rollback (INSTALL_FAILED_VERSION_DOWNGRADE; uninstall wipes the offline journal), so every risky path ships behind a flag evaluated locally from the signed, monotonically-versioned config bundle — a Device that reconnects to a stale edge must refuse to downgrade the bundle, or your kill switch silently reverts mid-service and you spend two hours believing it didn't work. Forced updates degrade, not block: below min_supported the Device enters grace mode (local order entry and printing continue, sync pauses, non-modal banner); hard block only after min_supported_hard and only outside configured service hours. A recovery build channel (export journal, install a named version, show device ID) is installed at provisioning and updated twice a year.
Support tooling is not optional and lands by M5, before pilot Sites: event-log export → replay into the simulator, force-resync, audited surgical repair, and automatic pseudonymised bundle upload when the divergence detector fires. A divergence alarm without forensics is an alarm you cannot act on.
#10. Decision records
ADR-001 — The Check is an event-sourced aggregate with a deterministic fold.
Context: multiple writers (Device + cloud in v1, Devices in v2 core/T2), partitions, and a fiscal requirement that a sealed Check is immutable with corrections as new documents.
Alternatives: single-writer + handoff (needs a lock service, needs a leader, circular, and "the manager is in the walk-in holding the lock" is fatal); CRDT (converges but cannot express "void is absorbing" or "payments are additive"; merging two valid documents can produce an invalid one); document/field-level LWW (Firestore/Realm shape).
Consequences: the log is the audit journal; conflict semantics are ours and are the product; fold changes are gated by fold_version; every developer must think in commutativity. LWW costs 0.5–2% of check value at a busy Site [estimate] and the operator fires a server before he blames the software.
ADR-002 — One money engine, Kotlin :domain, compiled to JVM and Android.
Context: the two research inputs mandated mutually exclusive things (a single TypeScript pricing engine; Kotlin at the counter with zero bridging). Writing the engine twice guarantees permanent penny mismatches at close.
Alternatives: run a JS runtime inside the Kotlin app (re-imports ~5.5 eng-months of bridging tax plus a second GC on a 2 GB device — rejected); Rust → WASM with FFI on both sides (+3.5–5.0 eng-months, a third language).
Consequences: the backend is JVM/Ktor; the back office is React against a generated OpenAPI client rather than sharing a domain package; AI-assist on backend CRUD drops ~10% [estimate]; hiring shifts to Kotlin, which is fine in Kyiv/Lviv/Prague.
ADR-003 — Android-only Kotlin/Compose at the counter; no browser, no WebView, no iPad in v1. Context: every peripheral and payment SDK in the certified matrix is a Java/Kotlin AAR; the beachhead's hardware is EUR 579–639 Android AIOs. Alternatives: React Native (+~5.5 eng-months of TurboModules and 0.15–0.25/quarter forever); Flutter (same tax, smaller CEE pool, Impeller on Rockchip GPUs unvalidated); PWA (no TCP socket, ever). Consequences: iPad is excluded from v1 through v3; the CMP module seam stays inert (+6–10 eng-months to activate); the reversal trigger is a beachhead move or a failed Kotlin hire in 8 weeks.
ADR-004 — Bespoke device↔cloud sync over HTTPS + WebSocket with our own event envelopes.
Context: every managed sync product ships LWW or server-rebase defaults and none does venue-LAN peer sync; MongoDB Atlas Device Sync's deprecation is the cautionary tale. A POS must outlive any sync vendor by 10 years because tax retention says so.
Alternatives: PowerSync as pure opaque transport (licence resolved: FSL → Apache-2.0 [verified], saves 2 eng-months); ElectricSQL (read-path only); Firestore/Realm (LWW — disqualified).
Consequences: we own resume, backfill, snapshot horizon, tombstones and purge (3.5 eng-months [estimate]). PowerSync remains adoptable later only if the event log stays the wire format.
ADR-005 — T0 single-Station in v1. The Coordinator is an interface, not a box. Context: LAN multi-station is a ~9 eng-month delta and a market question, not an engineering one — it hinges on assumption A8 (under 40% of the CZ beachhead runs 2+ Stations). Alternatives: T2 elected leader in v1 (+9.0 eng-months, election/fencing on the critical path); T3 dedicated Pi hub (cancelled — a Pi with NVMe in a 45 °C cabinet is a truck roll, and correctness must never depend on it). Consequences: the epoch, the lease and the conflict table ship in MVP so T2 is additive; if A8 is false, MVP slips ~3 months.
ADR-006 — Three number spaces; Business Day and Check numbers are leased; Fiscal Document numbers are never ours.
Context: conflating these creates a compliance incident, not a bug. Gapless fiscal sequences are a hard requirement in PL/RO/IT/HU [known].
Alternatives: one global sequence (impossible offline); server-allocated on close (breaks N1); wall-clock Business Day (a dead RTC cell puts a whole shift in the wrong Daily Close).
Consequences: leases carry epochs and burn unused numbers; the >5 min skew rule blocks tender; ReassignBusinessDay exists as an audited repair but is not the primary mechanism.
ADR-007 — Raster-only printing; raw TCP 9100 at the till, CloudPRNT pull in the kitchen; intent-log + verify queue.
Context: CEE bleeds on code pages (CP852/1250/866/1251); the text fast path doubles per-model bugs; a lost HTTP response on a push printer is genuinely ambiguous.
Alternatives: text fast path (2–4× faster per ticket, a 40-line kitchen ticket goes ~0.8 s → ~2.5 s [estimate] — reinstate for the first customer who measures it); browser printing (rejected on the socket argument alone).
Consequences: one rendering path; every ticket carries a 4-hex job id and a per-Station monotonic ticket number on paper; ambiguity is resolved by a human reading *** POSSIBLE REPRINT ***, which is a legitimate part of an exactly-once protocol when the alternative is silence.
ADR-008 — Semi-integrated payments behind a PaymentTerminal port; explicit unknown state.
Context: Stripe/Adyen hold EMV L2/L3 for their whole reader estate; we do zero certification, forever.
Alternatives: fully-integrated or app-on-reader (drags PCI scope up to SAQ C and needs a QSA opinion — Apps-on-Devices is v3 at the earliest); building a payfac (~EUR 333M TPV break-even).
Consequences: the tip prompt is rendered by the reader, not by us — the CFD shows the total and our ticket reconciles; terminal ownership is epoch-fenced; nightly PSP reconciliation by idempotency key is base scope, not a phase-3 nicety.
ADR-009 — Shared-schema multi-tenancy, org_id leading, RLS as a backstop only.
Context: 10,000 Postgres databases is an ops project, not a product; schema-per-tenant makes migration time scale linearly with tenants.
Alternatives: DB-per-tenant, schema-per-tenant (both rejected); RLS as the primary mechanism (rejected — SET LOCAL is transaction-scoped, so any query outside a transaction gets whatever the pooled connection last had).
Consequences: org_id is the leading column of every composite PK and index, which also makes future app-level sharding mechanical and makes the horizontal-only Hetzner rule cheap; a lint rule enforces both TenantContext and "no query outside a transaction"; accept 3–8% planner overhead on complex joins [estimate], exempt the analytics replica.
ADR-010 — The Menu Snapshot is an immutable, content-hashed, compiled artefact. Context: offline pricing must be bit-identical to server pricing, and the operator must be able to see a diff and roll back. Alternatives: Device-side resolution of the catalog graph (guarantees drift); server-side pricing per keystroke (violates N1). Consequences: publish is a fan-out job; the Device does zero resolution except the time-of-day price-list selection, which is bounded by the Coordinator's price-list epoch; menu correctness becomes testable via golden snapshots.
ADR-011 — Guest PII lives in a separate crypto-shreddable store; the financial log holds guest_id only.
Context: GDPR Art. 17 erasure versus tax retention (Art. 17(3)(b) [known]), against an immutable event log. Retrofitting erasure into an immutable log is a 3-month nightmare [estimate].
Alternatives: PII inline in events (unfixable); delete-the-receipt (illegal).
Consequences: erasure = crypto-shred the PII record + tombstone guest_id; tombstones replicate over the sync channel with per-Device ack and per-Device revocable keys; rendered ticket bitmaps containing a Guest name are never persisted beyond a 24 h retry window; on-device Guest cache is capped at today's Checks plus loyalty balances for Guests seen today.
ADR-012 — Postgres is the whole job and messaging substrate until 200 Sites.
Context: four long-running flows (Daily Close, PSP capture/refund sagas, menu ingestion, nightly exports) do not justify Temporal's operational surface or its USD 100/mo Essentials floor [verified], and LISTEN/NOTIFY genuinely carries fanout to 200 Sites.
Alternatives: Temporal/Restate/DBOS; NATS JetStream; ClickHouse from day one.
Consequences: transactional outbox + 3 eng-weeks FOR UPDATE SKIP LOCKED workers, the four flows written as explicit state machines ([estimate]); each deferred component has a numeric reinstatement trigger rather than a feeling.
#11. What we deliberately do NOT build
| Not built | Why, architecturally | Trigger that reinstates it |
|---|---|---|
| LAN multi-station (T2) in v1 | 9.0 eng-months of election, fencing, epoch-reuse and peer rediscovery for a capability under 40% of the beachhead needs [estimate] | Assumption A8 proves false, or v2 core |
| T3 dedicated hub SKU | Correctness must never depend on a box in a hot cabinet; it buys only Coordinator reliability, which T2 election delivers in <8 s | Never |
| Store-and-forward offline card auth | No PSP will underwrite it on acceptable terms, and the claim we make instead is honest | Never |
| Table service, coursing, seat splits, transfers | Roughly doubles the order domain and adds ~30% to the menu engine. Also a different ICP than canon §1's counter-service-only, single-station-first beachhead, so it cannot be funded before the counter-service base is at ~150 Sites | v3 (P4+), not v2 core |
| iPad / iOS target | Every peripheral SDK is a Java AAR; the CMP seam stays inert | Never in v1–v3 (+6–10 eng-months) |
| Self-order kiosk, unattended EMV, drive-thru | 4.5 eng-months of app plus 5–8 of unattended EMV certification plus 1.0–1.5 of EAA/ADA kiosk accessibility [estimate] | Never in this plan |
| CRDT anywhere near money | Merging two valid documents can produce an invalid one; permitted only for free-text notes, which we do not have | Never for financial state |
| Field-level LWW sync products | The failure mode is silent and gets a server fired | Disqualified |
| NATS JetStream, ClickHouse, second cell | Operating a bus and a second database for a workload we will not have inside the runway | 200 Sites / a signed enterprise contract |
| Temporal or any durable-execution vendor | Four flows | >8 long-running flows |
| Text-to-SQL over the open schema | A wrong revenue number that looks right destroys trust permanently, and it is a tenant-isolation hazard | Never |
| Per-restaurant white-label native guest apps | Apple Guideline 4.2.6; 500 listings is 500 review queues and 500 places a P1 can hide | Never |
| Live inventory, scheduling, payroll, reservations | Each is a product; we ship theoretical COGS, clock-in, and integrations | v3 / integrate |
| Surcharge / service-fee engine | PSD2 Art. 62(4)–(5) in the EU. Mandatory Service Charge is a distinct VAT-able entity in the tax engine instead | Never in the EU |
| Gift cards / multi-merchant stored value | Shared balance + offline redemption = double-spend by construction. Brand Vouchers (single-brand, face value, no top-up, no cash-out) replace them | Never |
| Probabilistic identity matching | Deterministic only (phone E.164 / email / loyalty ID / hashed PAR); a wrong merge is a privacy incident | 500 tenants + a labelled evaluation set |
| Our own fiscal signing, GUM certification, Spain | Co-defendant risk transfer is the argument, not price. Spain fines the producer [verified] | Never for ES; buy elsewhere |
#12. Where the architecture is load-bearing on unresolved assumptions
| Assumption | Architectural consequence if false |
|---|---|
A7 fsync is honest on the certified SKUs (test in week 3, smart PDU, 1,000 random power-cut offsets) | The intent log alone cannot be trusted; the printed per-Station monotonic Ticket number becomes the only dedupe primitive; print health +1.0 eng-months |
| A8 under 40% of the beachhead runs 2+ Stations | T2 moves into v1 sellable: +9.0 eng-months, election/fencing on the critical path, MVP slips ~3 months |
| A15 two senior Kotlin/Android engineers hired within 8 weeks | The entire stack decision reopens: React Native, +5.5 eng-months of bridging plus 0.15–0.25/quarter forever |
| A22 the AI-assist multipliers hold (D≈1.25×, A≈1.10×, B≈1.50×, C≈1.35×) | Re-baseline the effort plan; do not re-forecast revenue. AI compresses code, never the certification queue, the install, or the support rota |
Full assumption register, owners and deadlines: 08 — Pros, cons, difficulties and risks. Effort by domain and the phase gates that consume it: 07 — Roadmap, Team and Budget.