RestaurantBrain feasibility study

#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 epoch fencing 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

LayerCHOSENReversal 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 seamBeachhead 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 engineOne Kotlin :domain module, pure, no I/O, integer minor units, no bare dates, compiled JVM + AndroidRust → WASM with FFI both sides, +3.5–5.0 eng-months [estimate]. A TypeScript engine inside the Kotlin app is rejected
Backend servicesKotlin/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 clientsTypeScript + 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 storeSQLite via SQLDelight + SQLCipher, append-only event log + transactional outbox in the same transaction
Device ↔ cloud syncBespoke: HTTPS + WebSocket carrying our own event envelopes, server-assigned per-Site monotonic seq, resume/backfill/snapshot horizonPowerSync 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 semanticsEvent sourcing, per-Device append-only streams (UUIDv7 event_id, gapless device_seq, HLC ordering), pure deterministic foldCRDT rejected for money (Automerge permitted only for free-text notes). Field-level LWW disqualified
Primary databasePostgreSQL 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_dateDB-per-tenant and schema-per-tenant rejected
AnalyticsRead 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
RealtimeOwn WebSocket gateway, one connection per Device, per-Site monotonic seq with bounded gap replay, Postgres LISTEN/NOTIFY fanout to 200 SitesNATS JetStream — deferred to 200 Sites
Jobs / workflowsPostgres transactional outbox + SELECT … FOR UPDATE SKIP LOCKED; four long-running flows as explicit state machinesTemporal / Restate / DBOS — revisit above 8 long-running flows
Card-present paymentsStripe Terminal, semi-integrated, server-driven + local SDK; Stripe Reader S700 and S710 [verified]. Zero EMV L2/L3 work, everAdyen Terminal API (the hedge, and the only confirmed source of PAR [verified])
Fiscal signingBuy. FiscalProvider interface owned by us; CZ needs none; UA PRRO via Checkbox/Vchasno; PL via a GUM-homologated kasa-online printer partnerDirect national implementations, ~18–22 eng-months for DE+AT+FR+IT+ES+PT [estimate]
PCI postureSemi-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
PrintingRaster-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 allowsEpson 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 / MDMBase-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 agentEsper Bridge (USD 4/device/mo) above 200 Sites or with a kiosk fleet
InfrastructureHetzner, 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
ObservabilitySelf-hosted GlitchTip + Grafana/Loki/Prometheus from day oneSentry Business
Flags / configOpenFeature + 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
AIAnthropic 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
BillingStripe BillingChargebee

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

flowchart LR subgraph SITE["Site — counter, offline-capable"] TILL["TILL Station<br/>Sunmi D3, Kotlin/Compose<br/>SQLite+SQLCipher event log"] KDS["KDS / EXPO Station<br/>v1-sellable"] PRN["Epson TM-m30III<br/>raw TCP 9100"] KPRN["Star CloudPRNT<br/>kitchen, pull"] TERM["Stripe Reader S700/S710<br/>semi-integrated"] RTR["Teltonika RUT241<br/>4G failover"] end subgraph CLOUD["EU cell — Hetzner Falkenstein"] GW["ws-gateway"] API["api — modular monolith, Ktor"] WRK["worker — outbox, jobs"] AIG["ai-gateway"] PG[("PostgreSQL 17<br/>+ read replica")] OBJ[("Object storage<br/>images, snapshots, archives")] end subgraph WEB["Web clients"] BO["Back office — React/Vite"] SUP["Internal support console"] end subgraph EXT["External"] STRIPE["Stripe Terminal + Billing"] FISC["FiscalProvider<br/>CZ: none · UA: Checkbox/Vchasno"] SMS["SMS aggregator + email"] GBP["Google Business Profile"] ANTH["Anthropic API"] end TILL <-->|"WSS events, per-Site seq"| GW KDS <-->|"WSS tickets + acks"| GW TILL -->|"1-bit raster"| PRN KPRN -->|"HTTP poll + job ack"| GW TILL <-->|"local SDK"| TERM TILL -.->|"WAN via"| RTR GW --> API API --> PG WRK --> PG API --> OBJ BO --> API SUP --> API API --> STRIPE API --> FISC WRK --> SMS WRK --> GBP AIG --> ANTH API --> AIG

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)

flowchart TB subgraph APP["apps"] A1["api — HTTP/OpenAPI"] A2["ws-gateway — device sockets"] A3["worker — outbox + jobs"] A4["ai-gateway"] end subgraph MOD["modules inside api, package-enforced boundaries"] M1["platform: tenancy, auth, RBAC, config, flags, entitlements"] M2["catalog: Item/Variant/Option, price lists, Menu Snapshot compiler"] M3["domain (shared Kotlin): fold, pricing, tax, modifier resolution"] M4["orders: event ingest, projections, Business Day, numbering"] M5["payments: PaymentTerminal port, reconciliation, tips"] M6["fiscal: FiscalProvider port, receipt renderer, Daily Close"] M7["crm: Identity, Cluster, loyalty ledger, Voucher, consent"] M8["reporting: metric layer, 10 canonical reports"] M9["fleet: provisioning, telemetry, config bundles, health"] M10["comms: SMS/email, per-tenant senders"] M11["commerce: billing, onboarding, contracts"] end A1 --> M1 & M2 & M4 & M5 & M6 & M7 & M8 & M9 & M11 A2 --> M4 & M9 A3 --> M5 & M6 & M8 & M10 A4 --> M2 & M8 M2 --> M3 M4 --> M3 M5 --> M4 M6 --> M4 M7 --> M4

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

StateWANLANCoordinatorMust work in v1 (T0)
N0 normalupupupeverything
N1 WAN downdownupup (the till itself)order entry, kitchen print, cash, Check close, Receipt, loyalty against cached Guest set, Voucher redemption (signed, offline-verifiable)
N2 Coordinator downupupdownv1: not reachable — the till is the Coordinator. v2 core/T2: order entry, print, cash; no new leases
N3 island (printer unreachable)downdownorder 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) → CheckState is 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_seq jump 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. On CloseCheck + fiscal seal we write a frozen CheckSnapshot. 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 pairRuleWhy
AddLineAddLineUnion; distinct line_id ⇒ both existTwo staff adding food = two dishes. Never merge by SKU
VoidLine(L)ChangeQty(L)Void is absorbing — wins regardless of HLCAbsorbing ⇒ commutative ⇒ order-independent
VoidLine(L)VoidLine(L)Idempotent on line_id
VoidLine(L) after L is on a settled PaymentRejected in foldVoidRejected derived event → manager taskCannot silently reduce a settled Check
ChangeQtyChangeQtyDeltas, never absolutes. Sum, clamp ≥0qty=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 suppressedPrevents the 40%-off bug, keeps the audit trail
RecordPaymentRecordPaymentAdditive. Dedupe only by payment_id, never by amountTwo Guests really do pay EUR 20 each
Σ Payments > totalLegal state OVER_TENDERED, UI shows "REFUND DUE EUR 12.40"Never drop money, never auto-refund
CloseCheck ∥ anythingEvents 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 / availabilitySoft 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.

sequenceDiagram autonumber participant A as Device A — TILL-01 participant B as Device B — HANDHELD-02 participant C as Cloud ingest Note over A,B: LAN/WAN partition at 19:31:00 — both hold Check #241 A->>A: AddLine L1 2x Ribeye 62.00 · seq 41 · hlc t1/0/A B->>B: VoidLine L2 · seq 88 · hlc t1+2s/0/B B->>B: ChangeQty L1 +1 · seq 89 · hlc t1+3s/0/B A->>A: ApplyDiscount LOYAL20 · seq 42 · hlc t1+4s/0/A B->>B: ApplyDiscount LOYAL20 · seq 90 · hlc t1+1s/0/B Note over A,B: 19:33:10 partition heals A->>B: stream A[41..42] B->>A: stream B[88..90] A->>A: fold: union L1 · qty delta +1 · L2 VOID absorbing · discount dedupe keeps B's (lowest HLC), A's marked suppressed B->>B: fold: identical input set, identical order key → identical CheckState A->>C: outbox drain · event_id upsert · ON CONFLICT DO NOTHING B->>C: outbox drain C->>C: assign per-Site server_seq, project read model A->>C: publish hash(CheckState) fold_version=7 B->>C: publish hash(CheckState) fold_version=7 C-->>A: hashes equal — no divergence alert Note over C: mismatch would alert BEFORE the Check closes and auto-upload the pseudonymised log bundle

Property P4fold(π(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

ConcernMechanism
Local durabilityEvent append and outbox row in the same SQLite transaction. An event that exists locally but not in the outbox never syncs
Upstream idempotencyINSERT … ON CONFLICT (org_id, event_id) DO NOTHING RETURNING server_seq
PSP / external callsIdempotency-Key: {check_id, attempt_seq} on every payment_attempt; the acquirer reference is persisted forever
Backoff0.5s, 1s, 2s, 5s, 15s, 60s, then 60s with jitter, forever. A Device offline 3 weeks must still drain
ResumeDevice stores server_high_water_seq; GET /changes?since=<seq>&limit=1000, server seq is monotonic per Site, not global
BackfillServer 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
TombstonesEverything 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
ErasureGDPR tombstones replicate over the same channel with per-Device ack tracking and per-Device revocable data keys
Schema skewEvent 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 skewfold_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 blockA 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

CounterScopeGaps?AllocatorOffline behaviour
Ticket numberper Stationfreeany Devicelocal counter, cosmetic, printed on paper as the dedupe primitive of last resort
Check numberper Site per Business Daylegal, annoyingCoordinator, leased blocks of 100Device pre-leases ahead; leases carry epoch; unused numbers burn
Fiscal Document numberper fiscal regimeusually neverthe certified fiscal component, never our coderegime-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.

sequenceDiagram autonumber participant D as Coordinator TILL-01 epoch 7 participant L as Local intent log — SQLite fsync participant P as Epson TM-m30III — TCP 9100 participant D2 as Successor Coordinator epoch 8 D->>L: BEGIN · append FireTicket + print job 7F3A epoch 7 payload_hash state=SENDING · COMMIT+fsync D->>P: open socket 9100 · GS v 0 raster · flush P-->>D: partial — power cut before status read Note over D,P: ambiguity window: the ticket may or may not be on paper D--xD: process death D2->>L: inherit job table via the event log D2->>D2: epoch 7 to 8 durable monotonic · SENDING jobs go to the VERIFY queue never a resend queue D2->>P: DLE EOT status probe · check per-Station monotonic ticket counter on the roll alt printer confirms job 7F3A completed D2->>L: 7F3A = PRINTED · no reprint else ambiguous D2->>P: reprint with header "*** POSSIBLE REPRINT — job 7F3A — VERIFY ***" D2->>L: 7F3A = AMBIGUOUS (audited, counted in telemetry) end Note over D,D2: old Coordinator returns at epoch 7 D->>D2: dispatch job 7F3A at epoch 7 D2-->>D: REJECTED epoch 7 < max_seen 8 — become a peer, do not fail back automatically

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.

EntityKey fieldsNotes
orgorg_id PK, legal_name, country, cell, billing_refLeading column of every other table's PK
brand(org_id, brand_id), default_locale, tax_class_setMenu namespace. Price never lives here
site(org_id, site_id), brand_id, tz, business_day_start (default 04:00), currency, fiscal_profileOne physical restaurant
site_group(org_id, group_id) + membershipFree-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_seenRole is assigned from the Site plan at provisioning
employee(org_id, employee_id), name, per-Site role grants, pin_argon2id, pin_saltPIN 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_hlcProjection 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_seqThe 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_idThe 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_appliedAn 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, suppressedReduces 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 NULLunknown 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_atNumbers 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 countedImmutable, hash-chained, per Site per Business Day
cash_drawer_session(org_id, session_id), site_id, station_id, employee_id, opening_float, counted, expected, varianceEvery drawer open logs actor + reason
menu_snapshot(org_id, snapshot_id), content_hash, brand_id, site_group, channel_set, locale_set, artefact_refImmutable, content-hashed, atomically swapped by the Device
item / item_variant / option_group / optioncatalog graph, tax_class_id, attrs jsonb, min/max/free_count/free_policyDraft graph; the Snapshot is what ships
price_list / pricechannels[], site_group, priority, valid_from/to, recurrence jsonb, amount_minorAbsolute prices only, never percentage uplifts at read time
guest (PII store)(org_id, guest_id), name, per-Guest data keySeparate, 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, sourceDeterministic matching only until 500 tenants
cluster(org_id, cluster_id), primary_identity, versionThe derived set of Identities believed to be one Guest
loyalty_ledger(org_id, entry_id), member_id, delta_points, reason, prev_hash, hashAppend-only, hash-chained
vouchervid UUIDv7, member_id, face_value, expiry, Ed25519/COSE token, spent_atSigned, single-use, offline-redeemable
consent_record(org_id, identity_id, channel, purpose), policy_text_hash, ui_surface, granted_atAppend-only. ui_surface has no pos_staff value and the API rejects it
erDiagram SITE ||--o{ CHECK : "hosts" SITE ||--o{ DEVICE : "has" EMPLOYEE ||--o{ CHECK : "opens" EMPLOYEE ||--o{ DISCOUNT : "authorises" CHECK ||--o{ CHECK_EVENT : "is folded from" CHECK ||--o{ LINE : "contains" CHECK ||--o{ PAYMENT : "is tendered by" CHECK ||--o{ DISCOUNT : "carries" CHECK ||--o| FISCAL_DOCUMENT : "sealed by" CHECK }o--o| GUEST : "identified as" LINE ||--o{ LINE_OPTION : "selects" LINE }o--|| ITEM_VARIANT : "priced from" ITEM_VARIANT }o--|| ITEM : "of" OPTION_GROUP ||--o{ OPTION : "offers" ITEM ||--o{ OPTION_GROUP : "attaches" GUEST ||--o{ IDENTITY : "resolves to" GUEST ||--o{ CONSENT_RECORD : "grants" GUEST ||--o{ VOUCHER : "holds" MENU_SNAPSHOT ||--o{ LINE : "stamped on"

#4.1 Architecture invariants (non-negotiable)

#Invariant
1All money is integer minor units. Never a float, in any language, anywhere
2unit_price_minor, tax_rate_bp, menu_snapshot_id are snapshotted onto the Line and never recomputed
3Fiscal Document numbers come only from the certified fiscal component; Check numbers from Coordinator-leased blocks; Ticket numbers are cosmetic
4Business Day is leased; >5 min clock skew blocks close and tender
5Every lease and every print job carries a durable monotonic epoch
6Terminal ownership is a lease with the same fencing epoch, plus nightly PSP reconciliation by idempotency key
7payment_attempt has an explicit unknown state; idempotency key {check_id, attempt_seq}; SCA soft-declines classified in-flight and filtered before reconciliation matching
8The acquirer transaction reference is persisted forever; referenced refunds are mandatory
9Guest PII in a crypto-shreddable store; erasure tombstones replicate with per-Device ack; rendered ticket bitmaps never persist beyond a 24 h retry window
10consent_record.ui_surface has no pos_staff value; the cashier-consent button does not exist and cannot be added
11The migration importer sets marketing suppression by provenance; the segment builder cannot clear it
12Fiscal logic lives in a slow-release, separately-versioned module
13The property suite is written by a human before the fold exists
14Release 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
15On-device retention: 7 days of closed Checks + all open Checks, hard alarm at 500 MB
16No query outside an explicit transaction (lint-enforced), or SET LOCAL app.org_id and RLS are theatre under PgBouncer transaction pooling
17Offline 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.

ConcernDesign
Variants vs OptionsSize is a Variant, never an Option. Otherwise per-size recipes, per-size SKUs and per-channel per-size pricing are unfixable later
NestingOption Groups nest to depth ≤3, validated at publish. Deeper is unreviewable and destroys the touch UI
Free countsfree_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 pricesThe 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
Taxtax_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
DiscountsReduce the taxable base proportionally across tax classes
Service Charge vs TipService Charge is VAT-able revenue modelled as a distinct entity; Tip is outside VAT scope. They are never interchangeable, in schema or in UI
AllergensHeld on the ingredient, derived through the recipe DAG, with a mandatory manual override layer (EU FIC 1169/2011 [known])
Languagetranslation(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 / costA 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
SafetyGolden-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

ConcernDesign
TransportWebSocket, 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)
OrderingEvery 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
FanoutPostgres 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
LivenessThe 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 devicesDo 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 budgetfinger-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 suppressionSuppress 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-aggregates

Domain 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 pointModelShapeGuardrail
Menu ingestion (PDF/photo → draft catalog)claude-opus-5Document + image blocks, citations on, JSON-Schema output, plus a second numeric cross-check passNever 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-5Same pipelineSame review UI. This is the switching-cost killer
Menu-engineering narrativeclaude-sonnet-5SQL does the classification; the model writes the paragraphNumbers never come from the model
NL analyticsclaude-opus-5Tool-calling over the fixed metric layer. get_sales, compare_periods, get_pmix, get_laborText-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 repliesclaude-sonnet-5Draft onlyNever auto-send below 4★
Classification, ingredient matchingclaude-haiku-4-5
AI tier-0 support agentclaude-sonnet-5Reads Device telemetry, files the ticket before the restaurant callsCannot change tenant data
Anything nightlyBatch 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

EnvironmentContentsPurpose
devephemeral per-branch Postgres, seeded Site, emulated DevicesFeature work
labphysical rig: 2 Sunmi D3, 1 Elo KDS, TM-m30III, TM-U220II, Star TSP143IV, drawer, RUT241, UniFi AP, UPS, smart PDU for scripted power cutsThe only place device truth exists. Capex EUR 8–12k rig 1, second CI rig EUR 8–10k by M12
stagingproduction-shaped single cell, synthetic Friday-rush loadRelease candidate soak
prodone 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 + FOR UPDATE SKIP LOCKED workers, the four flows written as explicit state machines (3 eng-weeks [estimate]); each deferred component has a numeric reinstatement trigger rather than a feeling.


#11. What we deliberately do NOT build

Not builtWhy, architecturallyTrigger that reinstates it
LAN multi-station (T2) in v19.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 SKUCorrectness must never depend on a box in a hot cabinet; it buys only Coordinator reliability, which T2 election delivers in <8 sNever
Store-and-forward offline card authNo PSP will underwrite it on acceptable terms, and the claim we make instead is honestNever
Table service, coursing, seat splits, transfersRoughly 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 Sitesv3 (P4+), not v2 core
iPad / iOS targetEvery peripheral SDK is a Java AAR; the CMP seam stays inertNever in v1–v3 (+6–10 eng-months)
Self-order kiosk, unattended EMV, drive-thru4.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 moneyMerging two valid documents can produce an invalid one; permitted only for free-text notes, which we do not haveNever for financial state
Field-level LWW sync productsThe failure mode is silent and gets a server firedDisqualified
NATS JetStream, ClickHouse, second cellOperating a bus and a second database for a workload we will not have inside the runway200 Sites / a signed enterprise contract
Temporal or any durable-execution vendorFour flows>8 long-running flows
Text-to-SQL over the open schemaA wrong revenue number that looks right destroys trust permanently, and it is a tenant-isolation hazardNever
Per-restaurant white-label native guest appsApple Guideline 4.2.6; 500 listings is 500 review queues and 500 places a P1 can hideNever
Live inventory, scheduling, payroll, reservationsEach is a product; we ship theoretical COGS, clock-in, and integrationsv3 / integrate
Surcharge / service-fee enginePSD2 Art. 62(4)–(5) in the EU. Mandatory Service Charge is a distinct VAT-able entity in the tax engine insteadNever in the EU
Gift cards / multi-merchant stored valueShared balance + offline redemption = double-spend by construction. Brand Vouchers (single-brand, face value, no top-up, no cash-out) replace themNever
Probabilistic identity matchingDeterministic only (phone E.164 / email / loyalty ID / hashed PAR); a wrong merge is a privacy incident500 tenants + a labelled evaluation set
Our own fiscal signing, GUM certification, SpainCo-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

AssumptionArchitectural 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+ StationsT2 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 weeksThe 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.