12 — Desktop Backoffice Specification (Electron)
Surface: the flagship operational console for Ghasi Melmastoon — an AI-first, offline-first, Electron desktop application installed on staff laptops at every property. This is the operational heart of the platform.
Companion docs: 02 Enterprise Architecture · 05 API Design · 06 Data Models §5 SQLite · 07 Security & Tenancy §15 Electron Hardening · 08 AI Architecture §9.2 Edge Inference · 09 Lock & Key Integration · ADR-0003 Electron Offline-First Desktop · ADR-0004 Lock Integration Abstraction · bff-backoffice-service · API_CONTRACTS
Stack — non-negotiable: Electron 30+ (Node 20 main process + Chromium renderer), Vite 5 + React 18 + TypeScript strict (renderer),
better-sqlite3+ SQLCipher (local store), ONNX Runtime Node (edge AI),electron-builder+electron-updater(signed installers + auto-update),keytar(OS keychain),node-serialport/node-hid(USB/serial encoder),@ghasi/ui-melmastoon(design system shared with web + mobile).There is no Tauri in this stack. ADR-0003 closes that question.
1. Scope & Audience
The desktop backoffice is a single installable program that runs the property's daily operations end-to-end without depending on the public internet. It is the only application a hotel of any size needs on a staff workstation. The cloud is the source of truth when reachable; the local SQLite database is the source of truth when not.
| Persona | What they do in the desktop app |
|---|---|
| Front-Desk Clerk | Walk-in capture, check-in / check-out, key issuance (mobile-key invite, PIN, encoded card), folio updates, cash collection |
| Housekeeping Lead | Assign tasks, balance staff load, flip room status, raise maintenance tickets, sign off shifts |
| Housekeeper (kiosk sub-mode) | Pick the next task, mark in-progress / done, escalate issues |
| Maintenance Tech | Triage tickets, log parts/labor, close work orders, schedule preventive maintenance |
| General Manager | KPI dashboard, AI suggestions inbox, exception triage, override approvals (rate, refund, cash variance) |
| Owner | Read-only management view across one or more properties; financial roll-up; AI insights inbox |
| Finance / Admin | Cash-drawer reconciliation, folio audit, refund approval, regulatory exports (PDF/Excel/CSV) |
| Marketing Reviewer | Approve guest-facing notifications, AI-drafted messages (HITL), promotional rate proposals |
| Chain Operator (Phase 2) | Tenant-switcher overlay; cross-property dashboard; central rate strategy roll-out |
1.1 Operational climate
The desktop is built for the climate it must operate in:
- Bandwidth ceiling: 256 kbps shared across the property is a realistic baseline in our Phase-1 markets (Afghanistan, Tajikistan, Iran). The app must remain fully usable on links that are slower than that.
- Offline windows: 1 to 12 hours is the design target; up to 7 days is the maximum supported by the offline grace policy (configurable per tenant).
- Hardware floor: Intel i3 or equivalent / 8 GB RAM / x86_64; Windows 10+, macOS 12+, Ubuntu 22.04+ / Debian 12 / Fedora 39 / RHEL 9. ARM64 is Phase 3.
- Power: mixed mains + UPS; we assume the laptop will lose power without warning. Every state transition is crash-safe via SQLite WAL + outbox.
The desktop is therefore not an "offline-tolerant" web app dressed in a chrome — it is a first-class native application with deep OS integration, peripherals, and signed update channels.
2. Why Electron over Tauri (summary)
The decision is captured in full in ADR-0003. The condensed argument:
| Concern | Electron | Tauri | Verdict |
|---|---|---|---|
| Lock vendor SDKs (TTLock, Salto, Assa Abloy) | Node bindings, first-class | Rust crates, often community-maintained, lagging | Electron — the vendor ecosystem is the constraint |
| Embedded SQLite ergonomics | better-sqlite3 synchronous; perfect for IPC bridging | rusqlite works but ergonomically further from our pattern | Electron |
| Edge AI parity with cloud | ONNX Runtime Node mirrors ai-orchestrator-service runtime | tract / ort-rs smaller ecosystem | Electron |
| Signed installers + auto-update | electron-builder + electron-updater battle-tested for line-of-business apps | Younger; Windows MSI signed-update is rougher | Electron |
| Hiring profile in target markets | TS / Node — abundant | Rust — scarce in AF / TJ / IR / PK | Electron |
| Peripherals (encoder USB / serial / receipt printer) | serialport, node-hid, node-thermal-printer mature | Possible but more friction | Electron |
| Code reuse with web + mobile | Shared TS domain, design tokens, i18n, AI provenance contract | Rust shell + JS renderer means two languages | Electron |
| Binary size | ~140 MB | ~10 MB | Tauri — but install size is not our constraint |
| Idle RAM | Higher (Chromium) | Lower (system webview) | Tauri — not a blocker on i3/8 GB |
The Tauri wins are real but never blocking; the Electron wins are blocking-or-near-blocking for our market and our team. The decision is final. The remainder of this document assumes Electron without restating the rationale.
3. High-Level Architecture
The desktop is a single Electron process tree. The main process (Node 20) is the trust boundary, owns the database, the network, the peripherals, the OS keychain, the AI inference worker, and the auto-updater. The renderer (Chromium) renders the UI and has no Node access — it talks to main exclusively through a typed contextBridge surface called window.melmastoon.
┌──────────────────────────────────── Operating System ──────────────────────────────────────┐
│ │
│ ┌────────────────────────────── Electron Main (Node 20) ──────────────────────────────┐ │
│ │ │ │
│ │ ┌─────────────────────┐ ┌──────────────────────┐ ┌──────────────────────────┐ │ │
│ │ │ better-sqlite3 + │ │ Sync Worker │ │ AI Worker (utility │ │ │
│ │ │ SQLCipher │ │ (worker_thread) │ │ process / ONNX Runtime) │ │ │
│ │ │ · Local store │ │ · /sync/v1/pull │ │ · Anomaly classifier │ │ │
│ │ │ · Outbox / Inbox │ │ · /sync/v1/push │ │ · Embeddings │ │ │
│ │ │ · FTS5 │ │ · Cursor mgmt │ │ · Demand smoothing │ │ │
│ │ │ · Migrations │ │ · Conflict policy │ │ · Image quality scorer │ │ │
│ │ └─────────────────────┘ └──────────────────────┘ └──────────────────────────┘ │ │
│ │ │ │
│ │ ┌─────────────────────┐ ┌──────────────────────┐ ┌──────────────────────────┐ │ │
│ │ │ Lock Encoder Worker │ │ Outbox Flusher │ │ Auto-Updater │ │ │
│ │ │ · node-serialport │ │ · Drains outbox │ │ · electron-updater │ │ │
│ │ │ · node-hid │ │ FIFO when online │ │ · Signed manifest │ │ │
│ │ │ · BLE (future) │ │ · Idempotency keys │ │ · Staged rollouts │ │ │
│ │ └─────────────────────┘ └──────────────────────┘ └──────────────────────────┘ │ │
│ │ │ │
│ │ ┌─────────────────────┐ ┌──────────────────────┐ ┌──────────────────────────┐ │ │
│ │ │ keytar │ │ KMS-backed device │ │ Native menus + deep │ │ │
│ │ │ · Refresh token │ │ key (Ed25519) │ │ links (melmastoon://) │ │ │
│ │ │ · DB key fragment │ │ · DPoP signer │ │ · Multi-language │ │ │
│ │ └─────────────────────┘ └──────────────────────┘ └──────────────────────────┘ │ │
│ │ │ │
│ │ ┌──────────────────────── IPC over `contextBridge` (only) ────────────────────┐ │ │
│ │ │ Zod-validated handlers · narrow API · per-call audit · rate-limited │ │ │
│ │ └─────────────────────────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────────────────────┘ │
│ ▲ │ │
│ │ ▼ │
│ ┌────────────────────────────── Preload (typed bridge) ───────────────────────────────┐ │
│ │ exposes: window.melmastoon = { auth, db, sync, ai, lock, printer, update, …} │ │
│ │ no `ipcRenderer`; no `require`; everything goes through one typed surface │ │
│ └─────────────────────────────────────────────────────────────────────────────────────┘ │
│ ▲ │ │
│ │ ▼ │
│ ┌─────────────────────────── Renderer (Chromium + React) ─────────────────────────────┐ │
│ │ · React 18 · Vite 5 · TS strict · @ghasi/ui-melmastoon · Zustand · React Query │ │
│ │ · nodeIntegration: false · contextIsolation: true · sandbox: true · webSecurity │ │
│ │ · CSP enforced via meta + Electron `webRequest.onHeadersReceived` │ │
│ │ · No vendor SDK imports; no `fetch` to lock vendors; no SQL strings; no `process` │ │
│ │ · UI talks to main exclusively via `window.melmastoon.*` │ │
│ └─────────────────────────────────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────────────────────────────────┘
The diagram is the contract. No code path may cross any of those boxes by any other route. CI enforces it (see §3.5 of ADR-0003 and the dependency rules in §30 Testing).
4. Process Model
4.1 Main process (Node 20)
The main process is a long-lived Node 20 program. It owns:
- Window lifecycle (
BrowserWindowfactory, child windows for printers / preview, single-instance lock). - The full network surface: every
fetchtobff-backoffice-service,iam-service.signIn,/sync/v1/pull|push,electron-updaterchannel. - The local SQLite database (
better-sqlite3opened in WAL mode, encrypted with SQLCipher). - The outbox flusher, sync worker, AI worker (utility process), lock encoder worker.
- The OS keychain interface (
keytar) for the refresh token and the SQLCipher key fragment. - The Ed25519 device key used to sign DPoP proofs against
bff-backoffice-service. - The auto-updater (
electron-updater). - The native menu bar (multi-language), tray icon, OS notifications.
- Deep links (
melmastoon://reservation/<id>).
4.2 Renderer process (Chromium)
The renderer is a single React 18 SPA built with Vite 5 in production mode and Vite dev-server in development. It is sandboxed (sandbox: true), has no Node access (nodeIntegration: false), and runs in its own JavaScript context (contextIsolation: true). It cannot:
- Import
node:*modules,child_process,fs,net, orelectronitself. - Call
fetchto lock-vendor or AI-vendor endpoints — those routes are owned by main. - Access the SQLite file or its key.
- Open arbitrary URLs (
shell.openExternalis blocked from renderer; main mediates an allow-list).
It can only call window.melmastoon.*.
4.3 Preload script
The preload script runs in an isolated context with access to a narrow set of Electron APIs (contextBridge, ipcRenderer.invoke, ipcRenderer.on). It exposes one global — window.melmastoon — typed in electron/preload.ts. It does not expose ipcRenderer, process, require, or any other capability.
import { contextBridge, ipcRenderer } from 'electron';
import type { MelmastoonBridge } from '@ghasi/ui-melmastoon/desktop-types';
const bridge: MelmastoonBridge = {
auth: { /* see §6.1 */ },
db: { /* see §6.2 */ },
sync: { /* see §6.3 */ },
ai: { /* see §6.4 */ },
lock: { /* see §6.5 */ },
printer: { /* see §6.6 */ },
update: { /* see §6.7 */ },
device: { /* see §6.8 */ },
events: { /* see §6.9 */ },
keychain:{ /* see §6.10 */ },
};
contextBridge.exposeInMainWorld('melmastoon', bridge);
4.4 Security posture (the bare floor)
These are non-negotiable and enforced by CI lint rules (eslint-plugin-electron-security, plus a custom rule that asserts every BrowserWindow constructor uses the locked baseline):
new BrowserWindow({
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
webSecurity: true,
allowRunningInsecureContent: false,
webviewTag: false,
enableRemoteModule: false, // legacy guard; module is gone in modern Electron
preload: path.join(__dirname, 'preload.js'),
},
});
The renderer runs with a strict CSP enforced both by a <meta http-equiv="Content-Security-Policy"> tag and by webRequest.onHeadersReceived so it cannot be removed at runtime. The CSP is identical in shape to the web spec; see 07 §15.3.
The remote module is never added back. If a future PR introduces it, CI fails.
5. Repository Layout
The desktop app lives at apps/desktop-backoffice/ inside the ghasi-melmastoon monorepo. It shares @ghasi/ui-melmastoon, @ghasi/domain-primitives, and the platform i18n bundle with the web and mobile surfaces.
apps/desktop-backoffice/
├── electron/ Main + preload + workers (Node 20)
│ ├── main.ts Electron main entry
│ ├── preload.ts contextBridge API surface (window.melmastoon)
│ ├── ipc/ IPC handler registry
│ │ ├── auth.handlers.ts
│ │ ├── db.handlers.ts
│ │ ├── sync.handlers.ts
│ │ ├── ai.handlers.ts
│ │ ├── lock.handlers.ts
│ │ ├── printer.handlers.ts
│ │ ├── update.handlers.ts
│ │ ├── device.handlers.ts
│ │ ├── events.handlers.ts
│ │ └── keychain.handlers.ts
│ ├── workers/
│ │ ├── sync-worker.ts Long-running sync engine (worker_thread)
│ │ ├── ai-worker.ts ONNX inference (utility process)
│ │ ├── outbox-worker.ts Outbox flush (worker_thread)
│ │ └── lock-encoder-worker.ts USB/serial encoder (worker_thread)
│ ├── db/
│ │ ├── schema.ts better-sqlite3 schema + migrations
│ │ ├── migrations/ Numbered SQL migrations (0001, 0002, …)
│ │ ├── repositories/ Per-aggregate repo (no SQL leaks out)
│ │ │ ├── reservation.repo.ts
│ │ │ ├── room.repo.ts
│ │ │ ├── housekeeping.repo.ts
│ │ │ ├── folio.repo.ts
│ │ │ ├── key-credential.repo.ts
│ │ │ ├── outbox.repo.ts
│ │ │ ├── inbox.repo.ts
│ │ │ └── sync-cursor.repo.ts
│ │ └── encryption.ts SQLCipher key derivation + open
│ ├── auth/
│ │ ├── token-store.ts OS keychain via keytar (refresh + DB key fragment)
│ │ ├── device-binding.ts Ed25519 device key + tenant-CA cert
│ │ ├── dpop-signer.ts DPoP proof per request
│ │ └── session.ts Access JWT in memory; renewal loop
│ ├── lock-vendors/ On-prem encoder adapters
│ │ ├── generic-wiegand/ node-serialport + node-hid implementation
│ │ │ ├── wiegand.adapter.ts
│ │ │ ├── encoder-protocol.ts
│ │ │ └── card-format.ts
│ │ └── ttlock-ble/ Phase 2: BLE direct via @abandonware/noble
│ ├── ai-models/ Bundled ONNX models + signed manifest
│ │ ├── manifest.json Signed (Ed25519); model SHA-256 list
│ │ ├── anomaly-classifier-v1.onnx
│ │ ├── embeddings-multi-v1.onnx
│ │ ├── demand-smoother-v1.onnx
│ │ └── image-quality-v1.onnx
│ ├── menus.ts Native menus (per-locale strings)
│ ├── deep-links.ts melmastoon:// scheme handler
│ ├── auto-update.ts electron-updater wrapper
│ ├── notifications.ts OS notifications (Notification API)
│ ├── crash-reporter.ts Cloud Errors integration
│ └── window.ts BrowserWindow factory (locked baseline)
├── src/ React renderer (Chromium)
│ ├── main.tsx
│ ├── App.tsx
│ ├── routes/ React Router v6 routes
│ ├── features/
│ │ ├── dashboard/
│ │ ├── front-desk/
│ │ ├── housekeeping/
│ │ ├── maintenance/
│ │ ├── reservations/
│ │ ├── billing/
│ │ ├── reports/
│ │ ├── settings/
│ │ ├── ai-suggestions/
│ │ ├── cash-drawer/
│ │ ├── lock-actions/
│ │ └── onboarding/ First-run wizard
│ ├── components/ UI compositions on top of @ghasi/ui-melmastoon
│ ├── hooks/
│ ├── stores/ Zustand stores (UI state only — never domain truth)
│ ├── api/
│ │ ├── melmastoon.ts Typed wrapper around window.melmastoon
│ │ └── bff-client.ts Typed bff-backoffice client (proxied through main)
│ ├── i18n/ Locale bundles: ps, fa-AF, fa-IR, ar, tg-Cyrl, en
│ └── theme/ Theme tokens (per-tenant override)
├── electron-builder.yml Signed installer config per OS
├── vite.config.ts Renderer build
├── tsconfig.json strict, isolatedModules, exactOptionalPropertyTypes
├── package.json
└── README.md
The split is enforced: src/ may not import from electron/, and electron/ may not import from src/. Shared types live in @ghasi/ui-melmastoon/desktop-types (a tiny TS-only package) and are imported by both sides without leaking implementations.
6. window.melmastoon API Surface
The contextBridge surface is the only capability the renderer has. It is small on purpose — every method is a deliberate hole through the trust boundary, validated with Zod in main, audited, and rate-limited per device.
export interface MelmastoonBridge {
auth: AuthBridge;
db: DbBridge;
sync: SyncBridge;
ai: AiBridge;
lock: LockBridge;
printer: PrinterBridge;
update: UpdateBridge;
device: DeviceBridge;
events: EventsBridge;
keychain: KeychainBridge;
}
6.1 auth.*
export interface AuthBridge {
signIn(input: { email: string; password: string; mfaCode?: string }): Promise<SessionSummary>;
signOut(): Promise<void>;
refresh(): Promise<SessionSummary>;
listSessions(): Promise<DeviceSession[]>; // sessions on THIS device only
bindDeviceForOffline(input: { tenantCode: string; ownerOtp: string }): Promise<DeviceBinding>;
currentUser(): Promise<OperatorProfile | null>;
setProperty(propertyId: PropertyId): Promise<void>; // active property
requestStepUp(reason: 'refund' | 'rate_override' | 'cash_variance' | 'key_revoke_all'):
Promise<{ token: string; expiresAt: ISODateTime }>; // WebAuthn / TOTP step-up
}
interface SessionSummary {
user: OperatorProfile;
tenant: TenantSummary;
property?: PropertySummary;
expiresAt: ISODateTime; // access-token expiry (memory only)
offlineGraceUntil: ISODateTime; // signed by tenant policy
}
signIn proxies to iam-service.signIn directly — the BFF is not on the critical sign-in path. The refresh token is hashed and stored in the OS keychain via keytar; the access JWT lives in main-process memory only and is never exposed to the renderer (the renderer only sees expiresAt so it can show a session timer).
6.2 db.*
The renderer never sees raw SQL. Every aggregate has a domain-shaped repo method.
export interface DbBridge {
reservation: {
get(id: ReservationId): Promise<Reservation | null>;
list(filter: ReservationFilter): Promise<Page<Reservation>>;
upsert(input: ReservationDraft): Promise<Reservation>; // queues to outbox
addCharge(id: ReservationId, charge: ChargeInput): Promise<Folio>;
checkIn(id: ReservationId, input: CheckInInput): Promise<Reservation>;
checkOut(id: ReservationId, input: CheckOutInput): Promise<Reservation>;
addNote(id: ReservationId, note: string): Promise<Reservation>;
cancel(id: ReservationId, reason: CancellationReason): Promise<Reservation>;
};
room: {
list(filter: RoomFilter): Promise<Page<Room>>;
setStatus(id: RoomId, status: RoomStatus, reason?: string): Promise<Room>;
};
housekeeping: {
listTasks(filter: HousekeepingFilter): Promise<Page<HousekeepingTask>>;
assign(id: HousekeepingTaskId, staffId: StaffId): Promise<HousekeepingTask>;
start(id: HousekeepingTaskId): Promise<HousekeepingTask>;
complete(id: HousekeepingTaskId, notes?: string): Promise<HousekeepingTask>;
};
maintenance: {
listTickets(filter: MaintenanceFilter): Promise<Page<MaintenanceTicket>>;
open(input: MaintenanceTicketDraft): Promise<MaintenanceTicket>;
update(id: MaintenanceTicketId, patch: MaintenancePatch): Promise<MaintenanceTicket>;
close(id: MaintenanceTicketId, parts: PartLine[], labor: LaborLine[]): Promise<MaintenanceTicket>;
};
folio: {
get(id: FolioId): Promise<Folio | null>;
addCharge(folioId: FolioId, charge: ChargeInput): Promise<Folio>;
addPayment(folioId: FolioId, payment: PaymentInput): Promise<Folio>;
};
guest: {
search(query: string, limit?: number): Promise<Guest[]>; // FTS5
upsert(input: GuestDraft): Promise<Guest>;
};
cashDrawer: {
open(opening: MoneyMicro, pin: string): Promise<CashSession>;
record(entry: CashEntryInput): Promise<CashSession>;
close(closing: MoneyMicro, secondStaffPin: string): Promise<CashSession>;
};
}
All write methods queue to the outbox transactionally with the local state mutation; see §9.
6.3 sync.*
export interface SyncBridge {
status(): Promise<SyncStatus>;
pullNow(scopes?: AggregateScope[]): Promise<PullSummary>;
pushNow(): Promise<PushSummary>;
forceFullRebase(scopes: AggregateScope[]): Promise<void>; // drops cursors + re-pulls
getCursor(scope: AggregateScope): Promise<SyncCursor>;
setDataSaver(enabled: boolean): Promise<void>; // skip non-essential aggregates
pendingMutations(): Promise<OutboxStat>; // count + oldest age
}
interface SyncStatus {
online: boolean;
link: 'good' | 'flaky' | 'offline'; // rolling 60s window
lastPullAt?: ISODateTime;
lastPushAt?: ISODateTime;
pendingPushes: number;
oldestPendingAgeSeconds?: number;
conflicts: number; // unresolved
}
6.4 ai.*
export interface AiBridge {
runCapability<T extends AiCapability>(
capability: T,
input: AiInputFor<T>,
opts?: { hitlPolicy?: 'auto' | 'review_required'; preferEdge?: boolean }
): Promise<AiResult<T>>; // routes cloud-first if online, edge otherwise
listEdgeModels(): Promise<EdgeModelInfo[]>; // signed manifest entries
decideHitl(decision: HitlDecisionInput): Promise<HitlAck>; // accept / modify / reject
}
type AiCapability =
| 'anomaly.classify_booking'
| 'anomaly.classify_payment'
| 'embeddings.guest_profile'
| 'forecasting.demand_7d'
| 'imaging.quality_score'
| 'message.draft_pre_arrival'
| 'message.draft_post_stay';
Every result is provenance-stamped ({ model, version, edge, capability, traceId, runAt, costUnits }) and emitted to the local outbox as ai.inference.local.completed.v1 for cloud audit replay. See §10.
6.5 lock.*
export interface LockBridge {
issue(input: IssueKeyInput): Promise<KeyCredentialSnapshot>;
revoke(id: KeyCredentialId, reason: RevocationReason): Promise<KeyCredentialSnapshot>;
listDevices(propertyId: PropertyId): Promise<LockDevice[]>;
openEncoderSession(input: { encoderId: string; durationSec?: number }): Promise<EncoderSession>;
closeEncoderSession(sessionId: string): Promise<void>;
recordOfflineIssuance(input: ProvisionalIssuance): Promise<KeyCredentialSnapshot>;
onLockEvent(handler: (e: LockEvent) => void): Unsubscribe;
}
The renderer never speaks vendor protocols; it only expresses intent. The main process mediates between the cloud lock-integration-service (online path) and the local lock-encoder-worker (offline path). See §11.
6.6 printer.*
export interface PrinterBridge {
list(): Promise<PrinterInfo[]>;
printInvoice(invoiceId: InvoiceId, opts?: PrintOpts): Promise<void>;
printRegistrationCard(reservationId: ReservationId, opts?: PrintOpts): Promise<void>;
printGuestStatement(folioId: FolioId, opts?: PrintOpts): Promise<void>;
printShiftReport(sessionId: string, opts?: PrintOpts): Promise<void>;
}
Backed by the system print spooler via webContents.print() for browser-rendered documents and node-thermal-printer for receipt printers (USB / network). Tenant-configured templates per document.
6.7 update.*
export interface UpdateBridge {
checkForUpdates(): Promise<UpdateCheckResult>;
downloadUpdate(): Promise<void>;
applyUpdate(strategy: 'now' | 'on_quit'): Promise<void>;
status(): Promise<UpdateStatus>;
channel(): Promise<'alpha' | 'beta' | 'stable'>;
setChannel(c: 'alpha' | 'beta' | 'stable'): Promise<void>; // requires manager role
}
6.8 device.*
export interface DeviceBridge {
getId(): Promise<DeviceId>;
getCapabilities(): Promise<DeviceCapabilities>; // encoder?, printer?, BLE?, GPU?
getStorageBudget(): Promise<{ usedBytes: number; quotaBytes: number; warnAt: number }>;
getNetworkProfile(): Promise<'good' | 'flaky' | 'offline-recently'>;
getOfflineGrace(): Promise<{ until: ISODateTime; warningAt: ISODateTime }>;
}
6.9 events.*
A tiny subscription bus: the renderer subscribes to a typed channel; main pushes via webContents.send. Used for sync status, AI suggestion arrivals, lock events, OS-notification bridge, real-time KPIs from SSE.
export interface EventsBridge {
subscribe<C extends EventChannel>(channel: C, handler: (e: EventOf<C>) => void): Unsubscribe;
unsubscribeAll(channel?: EventChannel): void;
}
type EventChannel =
| 'sync.status'
| 'sync.conflict'
| 'ai.suggestion.new'
| 'lock.event'
| 'cash.drawer.opened'
| 'cash.drawer.variance'
| 'update.status'
| 'dashboard.kpi'
| 'arrival.feed'
| 'housekeeping.board';
6.10 keychain.*
The renderer never sees secret material. The keychain bridge exposes only existence checks and revocation:
export interface KeychainBridge {
has(slot: 'refresh_token' | 'db_key_fragment' | 'device_private_key'): Promise<boolean>;
forgetAll(): Promise<void>; // wipes keytar entries; forces re-pair
}
There is no get method. There never will be. Any PR adding one is rejected by the security review checklist.
7. Sync Engine
The sync engine is the heart of the offline-first design. It implements the canonical /sync/v1/pull and /sync/v1/push contract owned by sync-service and surfaced through bff-backoffice-service.
7.1 Loops
| Loop | Cadence (link = good) | Cadence (link = flaky) | Cadence (offline) | Conditions |
|---|---|---|---|---|
| Pull | every 30s | every 90s with jitter | suspended | per-aggregate cursor advances independently |
| Push | every 5s when outbox non-empty | every 15s with jitter | suspended | up to 100 mutations per batch, ≤ 256 KiB compressed |
| Heartbeat | every 60s | every 120s | — | confirms cursor liveness; updates last_heartbeat_at |
| Catch-up | on transition offline → good | — | — | prioritized: reservations + folios first, then rooms, hkt, maintenance |
| Token refresh | 60s before access-JWT expiry | — | — | DPoP-signed; uses keytar refresh token |
7.2 Pull pipeline
sync-worker.tick()
for scope in subscribedScopes(deviceCapabilities, dataSaverMode):
cursor = sync_cursors.get(scope)
body = { since: cursor, aggregates: [scope], maxBatch: 500 }
res = bff.post('/sync/v1/pull', body, { compression: 'br' })
for delta in res.deltas:
tx.begin()
applyToLocal(delta) // upsert / tombstone
inbox.insert({ id: delta.eventId, topic, payload, applied_at: now })
tx.commit()
sync_cursors.update(scope, res.nextCursor)
if res.hasMore: schedule_immediate_pull(scope)
Cursors are opaque tokens; the desktop never decodes them. A cursor older than 14 days returns 410 MELMASTOON.SYNC.CURSOR_OUT_OF_RANGE and the engine performs a full rebase pull for the affected scope (drops local rows older than the cursor and re-pulls from null).
7.3 Push pipeline
outbox-worker.tick()
rows = outbox.unpushed(limit=100, maxBytes=200*1024)
if rows.empty: return
body = { mutations: rows.map(r => ({
clientMutationId: r.client_mutation_id,
aggregateType: r.aggregate,
aggregateId: r.aggregate_id,
op: r.op,
payload: JSON.parse(r.payload),
baseVersion: r.base_version,
vectorClock: JSON.parse(r.vector_clock),
conflictPolicyHint: policyFor(r.aggregate),
})) }
idempotencyKey = ulid()
res = bff.post('/sync/v1/push', body, { headers: { 'X-Idempotency-Key': idempotencyKey }, compression: 'br' })
for result in res.results:
row = outbox.byClientMutationId(result.clientMutationId)
switch (result.status):
case 'applied':
case 'noop':
local.applyServerState(row.aggregate, result.serverState)
outbox.markPushed(row, result.status)
case 'conflict':
conflicts.record(row, result.conflict, result.serverState)
events.emit('sync.conflict', …)
case 'rejected':
outbox.markRejected(row, result.error)
events.emit('sync.conflict', …) // surfaces to operator
7.4 Conflict resolution
Per-aggregate policy is canonical in 02 §8.2. The desktop sends a conflictPolicyHint; the server validates against the canonical policy and rejects mismatches with 409 MELMASTOON.SYNC.MUTATION_REJECTED. For human-resolvable conflicts (notes, guest profile), a conflict tray surfaces both versions; the operator picks. For machine-resolvable (max-of room status, append-only folio charges), resolution is automatic and silent.
7.5 Bandwidth awareness
- Compression: every request and response uses
Content-Encoding: br(Brotli quality 4 — chosen to balance CPU on a baseline i3 vs payload size).gzipfallback if the BFF reports no Brotli. - Data-saver mode: when
setDataSaver(true), the engine skipsstaff_schedule,theme_cache, andai_suggestion_historyfrom pull, and defers AI suggestion drafting to next online window. - Aggregate prioritization: on
offline → goodtransition, the catch-up runner pullsreservation,folio_draft,key_credentialfirst, thenroom,housekeeping_task,maintenance_ticket, then everything else. - Batch sizing: push batches are sized by bytes (≤ 200 KiB pre-compression) and count (≤ 100), whichever hits first.
7.6 Telemetry
The sync worker emits:
sync.pull.completed(scope, deltaCount, bytes, durationMs, link)sync.push.completed(mutationCount, applied, conflict, rejected, bytes, durationMs)sync.cursor.advanced(scope, fromCursor, toCursor)sync.conflict.surfaced(aggregate, count)sync.offline.window(startedAt, endedAt, durationSec)
These events are batched to bff-backoffice-service's telemetry endpoint and projected into the cloud-side sync observability dashboards.
8. Local SQLite Schema
The schema is canonical in 06 §5 Desktop SQLite Schema. This section summarizes the Electron-specific runtime configuration and the maintenance schedule.
8.1 Runtime configuration
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
PRAGMA synchronous = NORMAL;
PRAGMA temp_store = MEMORY;
PRAGMA cache_size = -32768; -- 32 MB page cache
PRAGMA mmap_size = 268435456; -- 256 MB memory map
PRAGMA busy_timeout = 5000;
PRAGMA wal_autocheckpoint = 1000; -- pages
-- SQLCipher
PRAGMA key = "x'<hex of device-derived key>'";
PRAGMA cipher_page_size = 4096;
PRAGMA kdf_iter = 256000;
PRAGMA cipher_hmac_algorithm = HMAC_SHA512;
PRAGMA cipher_kdf_algorithm = PBKDF2_HMAC_SHA512;
The encryption key is derived via HKDF-SHA512 from (deviceKey ⊕ optional userPassphrase). The deviceKey lives in the OS keychain via keytar. Loss of the keytar entry forces re-pair — there is no recovery path. This is intentional: a stolen laptop cannot recover the database.
8.2 Tables (recap)
Replicated subset of cloud Postgres state, scoped to the active tenant + property:
reservations,rooms,room_status_log,housekeeping_tasks,maintenance_ticketsfolios,folio_drafts,key_credentials_snapshotguests,staff,staff_schedulestheme_cache,local_user_session
Desktop-only:
outbox,inbox,sync_cursors,ai_suggestions,cash_sessions,cash_entries,audit_log_local
8.3 FTS5
Three FTS5 virtual tables:
reservations_fts— guest name, notes, special requests (tokenizerunicode61 remove_diacritics 2).guests_fts— name, phone, email, ID-document number.maintenance_fts— title, description, asset name.
FTS indexes are kept in sync via SQLite triggers on the source tables.
8.4 Partitioning & vacuum
reservations,folio_drafts,room_status_log,audit_log_localare partitioned by month using acreated_yyyymmshadow column + monthlyattach/detachrotation. This keepsVACUUMcheap and lets us drop old partitions without rewriting the entire file.- A scheduled
VACUUMruns nightly at 03:00 local time when the outbox is empty and the link is good. If either condition fails, vacuum is deferred up to 7 days; on day 7, it runs regardless. wal_checkpoint(TRUNCATE)runs every 30 minutes during idle periods (no in-flight transactions).- A size guard raises a non-blocking warning at 1 GiB and a blocking warning at 4 GiB (default; configurable per tenant). The blocking warning offers
forceFullRebasefor old partitions.
9. Outbox & Inbox Local Pattern
9.1 Outbox
Every domain mutation in the renderer (db.reservation.upsert, db.room.setStatus, db.folio.addCharge, …) is implemented in main as a single SQLite transaction that:
- Updates the local aggregate row (with a bumped
versionand mergedvector_clock). - Inserts an
outboxrow with the canonical mutation envelope (clientMutationId,aggregate,aggregateId,op,payload,baseVersion,vectorClock,created_at). - Commits.
Because both writes are in the same transaction, a crash between local apply and outbox enqueue is impossible. clientMutationId is a ULID generated in main and is the idempotency key the server uses to dedupe replays per 05 §10.2.
9.2 Inbox
Every server delta received via /sync/v1/pull is inserted into inbox as an audit record (id = server eventId, topic, payload, applied_at) inside the same transaction that applies the delta to the replicated table. The inbox is read-only for the renderer (via db.events.listInbox for debugging) and is purged after 30 days.
9.3 Phase-2 push channel
Phase 1 is pull-only — the desktop polls /sync/v1/pull on its own cadence. Phase 2 adds an SSE channel (bff-backoffice-service → main) for push deltas. The sync worker subscribes via EventSource on a Node-HTTP variant; deltas are forwarded into the same applyToLocal pipeline. The desktop falls back cleanly to polling if SSE is unavailable (proxies, captive portals).
10. AI Worker (ONNX Runtime Node)
ONNX Runtime Node runs in a dedicated utility process spawned by main via app.whenReady().then(() => utilityProcess.fork(…)). The renderer never imports onnxruntime-node, never sees model bytes, and never has the file path. This isolates inference cost from the main process and keeps the UI responsive.
10.1 Bundled models
Models ship inside the installer at electron/ai-models/. A signed manifest (manifest.json, Ed25519 signature) lists each model with SHA-256 and capability:
{
"version": "2026.04",
"signature": "ed25519:base64:…",
"models": [
{ "name": "anomaly-classifier", "version": "1.3.0", "file": "anomaly-classifier-v1.onnx",
"sha256": "9b1d…", "capability": "anomaly.classify_booking", "sizeBytes": 4_215_104 },
{ "name": "embeddings-multi", "version": "1.0.1", "file": "embeddings-multi-v1.onnx",
"sha256": "1f8c…", "capability": "embeddings.guest_profile", "sizeBytes": 47_802_368 },
{ "name": "demand-smoother", "version": "1.1.0", "file": "demand-smoother-v1.onnx",
"sha256": "ab07…", "capability": "forecasting.demand_7d", "sizeBytes": 1_310_720 },
{ "name": "image-quality", "version": "0.9.2", "file": "image-quality-v1.onnx",
"sha256": "73de…", "capability": "imaging.quality_score", "sizeBytes": 6_291_456 }
]
}
At first run and at every auto-update, main verifies the manifest signature against a public key bundled with the installer, then verifies each model file's SHA-256 against the manifest. Tampered models refuse to load; main raises a blocking dialog and disables ai.runCapability until reinstall.
10.2 Routing
ai.runCapability(cap, input, opts)
if (online && link == 'good' && !opts.preferEdge) {
return bff.post('/bff/backoffice/v1/ai/run', { capability: cap, input })
.catch(_ => fallbackToEdge(cap, input))
}
return fallbackToEdge(cap, input)
fallbackToEdge(cap, input):
if (!edgeManifest.supports(cap)) throw AI_CAPABILITY_NOT_AVAILABLE
result = aiWorker.infer(cap, input)
outbox.enqueue('ai.inference.local.completed.v1', { capability: cap, edge: true, ... result.provenance })
return result
Cloud-first when feasible; edge-fallback when not. Every result carries provenance — { model, version, edge, capability, traceId, runAt, costUnits, hitlPolicy, decisionId? } — and is emitted to the local outbox as ai.inference.local.completed.v1. On next sync the cloud ai-orchestrator-service projects these into its eval log, preserving full audit even for edge inferences.
10.3 Execution providers
| OS | Default | Fallback |
|---|---|---|
| Windows | DirectML (dml) when GPU detected | CPU |
| macOS (Apple Silicon) | CoreML | CPU |
| macOS (Intel) | CPU | — |
| Linux | CUDA when nvidia-smi succeeds | CPU |
The chosen provider is part of the provenance record so we can correlate edge-inference quality with provider over time.
11. Lock Encoder Worker
The lock encoder worker owns USB and serial peripherals via node-serialport and node-hid. It is the only code path on the desktop that touches a real lock peripheral. The renderer expresses intent through lock.*; main routes to either the cloud lock-integration-service (online) or the local worker (offline).
11.1 Phase-1 vendor: Generic Wiegand / RFID encoder
┌────────────── Electron Main ──────────────┐
│ lock-encoder-worker (worker_thread) │
│ ├─ node-serialport (RS-232 / RS-485) │
│ ├─ node-hid (USB HID encoders) │
│ ├─ encoder protocol parser │
│ └─ card-format encoder (26-bit / 35- │
│ bit Wiegand; iCLASS / MIFARE 1K) │
└───────────────────────────────────────────┘
The worker manages encoder sessions:
openEncoderSession({ encoderId })
→ handle = serialport.open(encoderId, { baudRate: 9600, parity: 'none', dataBits: 8, stopBits: 1 })
→ handshake() → returns { sessionId, encoderModel, firmware, capabilities }
issue(input) // input: card UID, valid_from, valid_to, room IDs
→ frame = encoderProtocol.encodeIssue(input)
→ write(frame)
→ ack = await readAckOrTimeout(2_000ms)
→ if (ack.ok) return { vendorRef, encodedAt }
→ else throw LOCK_ENCODE_FAILED
closeEncoderSession(sessionId) → serialport.close(handle)
11.2 Online path
renderer.lock.issue(input)
→ main.ipc.lock.issue
→ bff.post('/bff/backoffice/v1/key-credentials', body) // cloud authority
→ cloud lock-integration-service routes to TTLock / Salto / Vostio adapter
→ response: { keyCredentialId, vendorRef, validity }
→ key_credentials_snapshot.upsert(local)
→ return KeyCredentialSnapshot to renderer
11.3 Offline path (provisional issuance)
When the link is offline, the renderer's lock.issue returns a provisional credential signed by a tenant CA-issued device certificate. The certificate is provisioned at device pairing (see §12). The provisional credential carries:
interface ProvisionalIssuance {
reservationId: ReservationId;
roomIds: RoomId[];
validFrom: ISODateTime;
validTo: ISODateTime;
vendor: 'generic_wiegand' | 'ttlock_offline_pin' | 'salto_offline_validity';
cardUid?: string; // for Wiegand encoder
pin?: string; // for TTLock OfflinePassword API
signature: string; // Ed25519 by device key (chained to tenant CA)
issuedAt: ISODateTime;
provisional: true;
}
For Wiegand, the encoder physically writes the card immediately; the operator hands it to the guest. For TTLock OfflinePassword, the worker generates a time-bound PIN locally (vendor SDK supports this) and shows it to the operator. On reconnect, sync-worker pushes a key.provisional_issued mutation; lock-integration-service materializes the canonical KeyCredential and clears the provisional: true flag. If the cloud rejects (e.g. inventory was reassigned), the desktop surfaces a high-severity alert and the operator must collect the card.
See 09 §5.4 Generic Wiegand / RFID and 09 §9 Offline Issuance for the full vendor contract.
12. Device Identity & Token Storage
12.1 Device key
At first install, main generates an Ed25519 keypair in the OS keychain via keytar (device_private_key slot). The public key is sent to iam-service.devices.register during onboarding and is signed by the tenant CA (a Cloud KMS-backed CA per tenant). The signed certificate is stored in the keychain (device_cert slot) and is the basis for:
- DPoP signing on every request to
bff-backoffice-service. - Provisional credential signing during offline lock issuance.
- Refresh-token proof-of-possession — a stolen refresh token without the device key is unusable.
12.2 Token storage
| Item | Storage | Notes |
|---|---|---|
| Refresh token (JWT, 30d) | OS keychain via keytar (refresh_token slot) | Stored as a SHA-256 hash; original cleared after first use of a session |
| Access token (JWT, 15min) | Main-process memory only (session.ts) | Never sent to renderer; renderer sees expiresAt only |
| DPoP private key | OS keychain (same device_private_key slot) | Non-exportable on Windows (TPM-backed where available), macOS Secure Enclave when available, libsecret on Linux |
| SQLCipher key fragment | OS keychain (db_key_fragment slot) | Combined with optional user passphrase via HKDF |
| Tenant CA cert chain | App-local (userData/ca-chain.pem) | Public, not secret |
12.3 Offline session validity
Tenant policy declares an offline grace window (default 7 days, max 14 days for Plus+ plans). The access token's offlineGraceUntil claim is signed by iam-service using the device-binding cert. If the desktop is offline beyond offlineGraceUntil:
- Local read operations continue.
- Local write operations are blocked with a clear "Reconnect required" banner. The operator must come back online once before resuming.
- A forced sign-in clears the local session if the operator chooses to re-authenticate.
This protects against a stolen laptop being used indefinitely off-network.
13. Auto-Update
The desktop ships with electron-updater configured against a signed manifest at https://desktop.melmastoon.ghasi.io/<channel>/{platform}-{arch}.yml. Channels: alpha, beta, stable. Default for production tenants: stable. Owners can opt their property into beta per device.
13.1 Cadence and behavior
| Event | Behavior |
|---|---|
| Cold start | Check once if last check > 6h ago |
| Online polling | Every 6 h while online |
| Manual | update.checkForUpdates() from settings |
| Update found | Download in background (resumable, throttled to 256 KiB/s by default — configurable) |
| Download complete | Notify user; default install strategy: on quit (next time the app is closed). Manager-role override: install now. |
| Emergency update (security flag in manifest) | Force a 30s warning then install at next idle moment (no in-progress check-in / encoder session) |
| Rollback | If post-install health check fails (db.migrationsApplied == false or ipc.bridgeAvailable == false), electron-updater reverts to the prior version and reports update.rollback telemetry |
13.2 Verification
- Signature:
electron-updaterverifies every artifact against the public key bundled in the installer. A signature mismatch aborts the install and recordsupdate.signature_invalid. - Manifest pinning: the public key is pinned at build time; an attacker swapping the manifest server cannot ship arbitrary updates.
- Channel lock: the channel cannot be silently switched server-side; a channel change requires a manager-role action on the device.
13.3 Database migration safety
On first launch after an update, the db/schema.ts migration runner:
- Takes a
db.backup()snapshot touserData/db-backups/<version>.bak. - Runs each pending migration inside a single SQLite transaction per migration.
- Verifies
pragma integrity_checkisok. - If any step fails, restores the backup and reports
update.migration_failed. The previous app binary is kept for manual rollback.
14. Installer & Signing
electron-builder produces all installers from one electron-builder.yml. Per-OS signing pipelines run in GitHub Actions with OIDC-issued short-lived credentials for the signing certificates (no long-lived secrets in CI).
| OS | Formats | Signing |
|---|---|---|
| Windows | MSI + NSIS .exe | Authenticode (EV cert), SHA-256 timestamp; SmartScreen reputation seeded by EV |
| macOS | universal .dmg (x86_64 + arm64) | Apple Developer ID + hardened runtime + notarization (xcrun notarytool) + stapling |
| Linux | AppImage + .deb + .rpm | GPG-signed; AppImage uses appimagetool with embedded signature; .deb/.rpm repos served at apt.melmastoon.ghasi.io and rpm.melmastoon.ghasi.io |
Naming: melmastoon-backoffice-<semver>.<ext> (e.g. melmastoon-backoffice-1.4.2.msi). Channel suffix when not stable: melmastoon-backoffice-1.5.0-beta.3.dmg.
Distribution endpoints:
https://desktop.melmastoon.ghasi.io/stable/win/
https://desktop.melmastoon.ghasi.io/stable/mac/
https://desktop.melmastoon.ghasi.io/stable/linux/
https://desktop.melmastoon.ghasi.io/stable/{platform}-{arch}.yml ← electron-updater manifest
Tenant admin portal embeds a one-click installer download per property; the link is short-lived and tenant-scoped so platform admins can revoke a tenant's installer access.
15. Internationalization & RTL
The desktop is multilingual end-to-end. The locale chain is:
- User override (set in Settings; persisted in
local_user_session). - Operator profile preference (from
iam-service). - Property
localeOverride(fromproperty-service). - Tenant
defaultLocale(fromtenant-service). - Platform default
en.
Phase-1 locales:
| Locale | Script | Direction | Primary market | Fonts |
|---|---|---|---|---|
ps | Pashto (Arabic) | RTL | Afghanistan | Noto Naskh Arabic, Vazirmatn fallback |
fa-AF | Dari (Arabic) | RTL | Afghanistan | Vazirmatn, Noto Naskh Arabic fallback |
fa-IR | Persian (Arabic) | RTL | Iran | Vazirmatn |
ar | Arabic | RTL | Cross-region | Noto Naskh Arabic |
tg-Cyrl | Tajik (Cyrillic) | LTR | Tajikistan | Noto Sans, Noto Sans Cyrillic |
en | Latin | LTR | Default | Inter, Noto Sans |
15.1 RTL handling
- Layout uses CSS logical properties exclusively (
margin-inline-start,padding-inline-end,inset-inline-start). Noleft/rightliterals in component CSS. - Direction is set on
<html dir="rtl">per locale; component code never reads direction directly. - Icons that imply direction (back arrows, breadcrumb chevrons, sort glyphs) flip via the
@ghasi/ui-melmastoondirection-aware icon set. - Native menus are localized (Electron's
Menu.buildFromTemplateaccepts the per-locale strings); macOS menu bar respects the OS bidi system. - Bidi text shaping (mixed Arabic + Latin numerals in folio amounts, mixed Cyrillic + Latin in Tajik) uses system fonts; we ship Noto Naskh Arabic and Vazirmatn inside the installer to guarantee rendering on bare-bones Linux.
- Date / number / currency formatting uses
Intl.DateTimeFormat,Intl.NumberFormat,Intl.RelativeTimeFormat. We never hand-format. Hijri-vs-Gregorian preference is per-tenant and read fromtenant-service.settings.calendarPreference.
15.2 Primary action positioning
In RTL, the primary action sits on the left of the dialog footer; in LTR, on the right. The @ghasi/ui-melmastoon/Dialog component handles this automatically; component callers pass primaryAction and secondaryAction as semantic props, never as positional children.
15.3 Right-aligned dock
The default dock (sync status, conflict tray, AI suggestions inbox) docks on the trailing edge (right in LTR, left in RTL). The renderer uses logical placement via the design system layout primitives.
16. Accessibility
Target: WCAG 2.2 AA for all critical operational surfaces (front-desk, housekeeping, billing, lock actions). Reports and analytics may degrade to AA-equivalent contrast under the high-contrast theme.
- Keyboard-first design. Every operator action has a shortcut. Examples:
Ctrl/Cmd+Kglobal command palette;Ctrl/Cmd+Nnew walk-in;Ctrl/Cmd+Icheck-in selected reservation;Ctrl/Cmd+Ocheck-out;Ctrl/Cmd+Hhousekeeping board;Ctrl/Cmd+Lissue lock key;Ctrl/Cmd+Shift+Pprint invoice. Shortcuts are listed in a?overlay and configurable per operator. - Screen readers. NVDA on Windows, VoiceOver on macOS, Orca on Linux are tested every release. ARIA roles and live regions for sync status, AI suggestion arrival, and lock events.
- Focus management. Modals trap focus; focus returns to the invoking element on close; the command palette restores focus to its caller.
- High-contrast theme. Selectable in Settings; respects OS contrast preference (
prefers-contrast: more). - Configurable font size. 90 % / 100 % / 115 % / 130 % global scale (persisted via
webContents.setZoomFactor); typography ramp scales linearly. - Color-blind safe palette. Status colors (clean / dirty / OOO / OOS, info / warn / critical) have shape + text labels in addition to color; tested against deuteranopia, protanopia, tritanopia simulators in CI.
17. Performance Budgets
Baseline hardware: Intel i3-10100 / 8 GB RAM / NVMe SSD / Windows 11. Budgets are p99 unless noted.
| Metric | Target |
|---|---|
| Cold start → dashboard interactive | < 4.0 s |
| Hot start (already warm) | < 1.5 s |
| Main process JS heap (idle) | < 200 MB |
| Renderer JS heap (idle dashboard) | < 250 MB |
| Renderer JS heap (front-desk + open dialog) | < 400 MB |
| Total RSS (idle) | < 600 MB |
| Total RSS (active) | < 900 MB |
| SQLite hot read p99 (single aggregate by id) | < 8 ms |
| SQLite list query p99 (today's reservations) | < 25 ms |
| SQLite full-text search p99 (guest by partial name) | < 35 ms |
| Outbox enqueue + commit p99 | < 12 ms |
| Sync pull (single 30s window, ~50 deltas) | < 5.0 s |
| Sync push (10 mutations, ~30 KB) | < 1.5 s on good, < 6.0 s on flaky |
| AI inference p99 — anomaly classifier (edge) | < 600 ms |
| AI inference p99 — embeddings (edge, batch 8) | < 900 ms |
| Renderer FPS during dashboard interaction | > 50 |
| Renderer FPS during housekeeping drag-drop | > 60 |
| Lock encoder open session | < 800 ms |
| Lock encoder issue card | < 2.0 s |
The performance test suite runs every PR (Playwright Electron + custom SQLite microbenches) and fails the build on regression beyond a 10 % drift.
18. Front-Desk Workflow Detail
The front-desk feature is the most-trafficked surface. The route tree:
/front-desk
├─ /arrivals today's expected arrivals (default landing)
├─ /in-house current guests
├─ /departures today's expected departures
├─ /walk-in walk-in capture
├─ /reservation/:id reservation detail (folio, key, notes, history)
└─ /find-guest FTS search across guests + reservations
18.1 Arrivals grid
A virtualized grid (using @ghasi/ui-melmastoon/DataGrid) of today's reservations sorted by ETA. Columns: Guest, Reservation, Room, Adults/Children, Channel, Folio Balance, ETA, Status, Action. The Action column is a single primary button: Check In (or Walk In if the reservation is missing).
18.2 Check-in flow
- Operator clicks Check In.
- A modal opens: confirm guest details (name, phone, email, ID number); upload ID document (optional offline; mandatory on jurisdictions that require it — driven by
property-service.policies). - Confirm room assignment (auto-assigned by
housekeeping-serviceif the room iscleanand matches type; otherwise operator picks from available). - Choose key delivery: mobile-key invite (SMS), PIN, encoded card (Wiegand encoder). The choice depends on
lock.listDevicesresults for the property. - Click Issue Key →
lock.issue(...)is called. Online path: cloud authoritative. Offline path: provisional credential issued locally and the operator hands the card to the guest immediately; sync materializes the canonical credential on reconnect. - The reservation transitions to
checked_in; folio is opened; cash drawer entry is recorded if any cash is collected up-front.
Every step writes to outbox transactionally. A network drop mid-flow does not leave the reservation in a broken state — the local row is checked_in and the outbox holds the mutations.
18.3 Walk-in
Walk-in is a fast-path: skip discovery; capture guest details + room + dates + rate plan in a single modal. Pricing is computed locally from the cached rate_plan snapshot; if the operator overrides the rate, a MELMASTOON.PRICING.OVERRIDE audit entry is added with their identity. On reconnect, pricing-service validates the rate; if rejected, the front-desk inbox surfaces a "rate review required" alert.
18.4 Mid-stay modifications
Add charges, change rooms, extend stay. Each is a db.reservation.* call that updates the local aggregate and queues outbox mutations with conflictPolicyHint: 'append_only' for folio entries and 'lww' for room change (the canonical policy is server_authoritative for state; the desktop sends a hint so the server knows it's not a stale write).
18.5 Check-out
- Display folio summary + final balance.
- Capture remaining payment (cash, card via terminal, MFS reference).
- Revoke key via
lock.revoke(...). - Print invoice / guest statement (
printer.printInvoice(...)). - Mark reservation
checked_out; transition the room status todirty; create ahousekeeping_taskof typeturnoverwith priority based on the next arrival's ETA.
18.6 Connectivity indicator
A persistent badge in the front-desk header shows Online, Flaky, Offline, with the count of pending push mutations and the age of the oldest. Clicking it opens the Sync tray — pull / push / conflict counts, last sync timestamps, force-pull / force-push buttons, data-saver toggle.
19. Housekeeping Board Detail
A drag-and-drop Kanban + staff timeline combo at /housekeeping. Columns: Pending, Assigned, In-Progress, Done, Verification.
19.1 Layout
- Top: staff timeline (rows = staff, columns = 30-min buckets). Tasks render as draggable chips on each row.
- Bottom: Kanban with the columns above. Each card shows room number, task type, priority, due-by, age.
- Right: filters (floor, room type, type, priority), and a map view for properties with floor plans (Phase 2).
Drag-and-drop assignment uses react-dnd with HTML5 backend; touch backend for tablet sub-mode. Every drop calls db.housekeeping.assign(...) which updates the local row and queues the outbox mutation.
19.2 Room status colors
Status colors are accessible (paired with text + shape) and consistent with the design system tokens:
| Status | Color (light) | Color (dark) | Shape |
|---|---|---|---|
clean | --ms-green-300 | --ms-green-700 | filled circle |
dirty | --ms-amber-300 | --ms-amber-700 | half circle |
inspected | --ms-blue-300 | --ms-blue-700 | filled square |
OOO (Out of Order) | --ms-red-300 | --ms-red-700 | hollow circle with cross |
OOS (Out of Service) | --ms-grey-400 | --ms-grey-600 | hatched square |
19.3 Checklist
Each task type has a checklist template (per tenant, configured in Settings). The housekeeper ticks items in the In-Progress column; on Done, the checklist is committed as a task.completed mutation with the checklist payload. The verifier (housekeeping lead) signs off in the Verification column.
19.4 Mobile-bridge (Phase 2)
Housekeepers may use the consumer mobile app's staff sub-mode on a personal phone. The phone app calls the same bff-backoffice-service endpoints (auth-scoped to housekeeper role); the desktop board reflects updates in near-real-time via SSE. The desktop is still the authoritative supervisor surface; the phone is task-execution only.
20. Maintenance Board Detail
A Kanban at /maintenance with columns: Open, Triaged, In-Progress, Awaiting-Vendor, Closed. Each ticket carries severity, asset, room, opened-by, age, SLA timer.
20.1 Vendor coordination panel
When a ticket transitions to Awaiting-Vendor, a side panel opens with vendor contact info (from a tenant-managed vendor registry), templated message drafts (AI-assisted via ai.runCapability('message.draft_pre_arrival', ...) reused for vendor outreach), and a quick-call action that opens the OS tel: handler for the vendor's number.
20.2 Preventive schedule
A calendar view of recurring preventive maintenance per asset (e.g., HVAC quarterly service, generator monthly start). Recurring tasks auto-create tickets one week before due-date.
20.3 SLA timer
Each ticket has an SLA defined by severity (e.g., Critical: 2h, High: 8h, Medium: 24h, Low: 7 days). The SLA timer counts down in the card; breaches turn the card red and emit maintenance.sla.breached.v1 to the cloud for escalation policy.
20.4 Asset registry
Read at /maintenance/assets. Each asset has serial, model, install-date, warranty status, attached rooms, history of tickets, parts catalog. Replicated to the desktop subset for offline triage.
21. Dashboard Composition
The dashboard at / is a composable widget grid. Each widget renders independently with its own skeleton state and data dependency. The grid layout is per-operator (persisted in local_user_session).
| Widget | Source | Skeleton | Lazy |
|---|---|---|---|
| Today's KPIs (occupancy, ADR, RevPAR, pickup) | bff-backoffice-service GET /dashboard (cached locally for 60s; recomputed from local SQLite if offline) | yes | no |
| Arrivals (next 6h) | local SQLite + SSE feed | yes | yes |
| Departures (next 6h) | local SQLite + SSE feed | yes | yes |
| In-house guests | local SQLite | yes | yes |
| AI suggestions inbox | local ai_suggestions table + SSE | yes | yes |
| Alerts inbox | local + SSE | yes | yes |
| Sync status | live from events.sync.status | no | no |
| Cash drawer status | local cash_sessions | yes | yes |
| Lock event tape | events.lock.event | yes | yes |
| Housekeeping board snapshot | local SQLite | yes | yes |
Widgets render through @ghasi/ui-melmastoon/Widget which handles skeleton, error boundary, retry, and stale-while-revalidate semantics. A widget that depends on a cloud-only source (e.g., AI cloud suggestions) shows a "Computed when online" inline note when offline.
22. AI Suggestion Surface
AI suggestions arrive via the ai.suggestion.new channel — either pulled from the cloud or generated locally by the AI worker. They land in an inbox-style UI at /ai-suggestions and as a dashboard widget.
22.1 Card
Each suggestion card shows:
- Capability (e.g., "Anomaly: payment retry pattern")
- Subject (entity link — reservation, payment, room, guest)
- Confidence (model output as a badge)
- Provenance (
Suggested by Vertex AI Gemini 2.5 Pro · prompt-v1.4.2 · ran 12 min agoorSuggested locally · anomaly-classifier v1.3.0 · CoreML) - Action chips: Accept, Modify, Reject (each records a
decisionsrow) - Why drawer: top features that drove the inference (for explainability)
22.2 HITL gate
Suggestions whose action is irreversible or guest-facing (refund, manual rate override, send pre-arrival message) require explicit operator action via a HITL gate. The decision is recorded with operator identity, timestamp, and full provenance via ai.decideHitl(...). Auto-applied suggestions (e.g., re-prioritize a housekeeping queue) are visible in the inbox with an Undo chip for 5 minutes.
22.3 Local generation
Edge-eligible capabilities run in the AI worker even when offline; the suggestion card shows a small "Edge" badge. On reconnect, the cloud ai-orchestrator-service may upgrade the suggestion (retries with the cloud model when budget allows); the inbox surfaces the upgrade as a new card linked to the original.
23. Cash Drawer Workflow
A first-class feature with strict audit. Route: /cash-drawer.
23.1 Open shift
The clerk opens a session at the start of their shift:
- Enters opening cash count (
MoneyMicroper currency — multi-currency drawers supported). - Enters their PIN.
db.cashDrawer.open(opening, pin)creates acash_sessionsrow instate = 'open', references the operator and the shift.- The session id is bound to subsequent cash entries until close.
23.2 Inline cash recording
When the operator selects "Cash" as a payment method on a reservation folio, the entry is recorded against the open session (db.cashDrawer.record(...)), tagged with reservation id, folio id, amount, currency, and timestamp. A drawer-balance HUD shows running totals.
23.3 Close shift
At end-of-shift:
- The clerk enters the closing cash count.
- The system computes expected vs counted; variance is highlighted.
- Two-staff sign-off is required: a second staff member enters their PIN to co-sign the close.
- If variance > tenant-configured threshold (e.g., 100 AFN), a manager-role override is required (step-up via WebAuthn / TOTP). Manager identity is recorded; the variance and reason are written to the local audit log and queued to the cloud for finance reconciliation.
23.4 Cross-shift handover
If the same drawer continues across shifts, the close-and-open is a single combined action with both signatures. The session boundaries remain distinct in audit.
24. Lock Action Surface
Route: /lock-actions and inline panels on reservation detail.
24.1 Issue / update / revoke
A unified KeyActionPanel component handles all three. The operator picks the credential format (mobile-key invite, PIN, encoded card), the validity window (defaults to reservation dates), and the rooms (defaults to assigned rooms). The action chip is Issue / Update / Revoke; each routes to lock.* and the result is stamped in the local key_credentials_snapshot.
24.2 Mobile-key invite preview
For TTLock and Salto mobile-key flows, the panel shows a preview of the SMS / email / WhatsApp invite the guest will receive (rendered with the tenant's brand tokens for consistency). The operator can edit the message; AI-drafted alternatives are offered via ai.runCapability('message.draft_pre_arrival', ...).
24.3 PIN generation
For vendors that support PIN credentials (TTLock OfflinePassword, Wiegand standalone keypads), the panel generates a PIN with controlled entropy and shows it once. The PIN is never persisted in plaintext on the desktop or in the cloud — only a hash is stored for verification.
24.4 Encoder session UI
For Wiegand encoders, the panel shows encoder status (connected / disconnected / busy), a "Read card" affordance for re-encoding existing cards, and a "Test encode" affordance for validating the encoder during onboarding. Encoder telemetry (open / close / encode counts, errors) is reported via the desktop telemetry channel.
25. Reports
Embedded reports at /reports with a left-rail list and a right-pane viewer. Phase-1 templates:
- Today's arrivals
- Today's departures
- Occupancy (daily / weekly / monthly)
- Revenue (by channel / rate plan / room type)
- Folio audit (by date range)
- Cash drawer reconciliation (per session)
- Housekeeping productivity (per staff)
- Maintenance work-orders summary
- Regulatory exports (per jurisdiction — Afghanistan/Iran/Tajikistan templates)
Filters: date range, property, room type, channel, currency. Export formats: PDF (via chrome-headless-shell already inside Electron), Excel (exceljs), CSV (custom). All reports are computed against local SQLite (so they work offline); cloud-augmented variants (e.g., year-over-year benchmarks) overlay when online.
Reports are templated; tenants can customize headers, footers, columns, and language. The template engine is a constrained subset of MJML for layout + Handlebars for substitutions, deliberately limited to prevent template injection.
26. Notifications & Alerts
26.1 OS notifications
The main process uses Electron's Notification API (which maps to Toast on Windows, Notification Center on macOS, libnotify on Linux). Severity classes:
| Severity | OS sound | Auto-dismiss |
|---|---|---|
info | none | 5 s |
warn | OS default | 10 s |
critical | OS critical alert | sticky until dismissed |
Examples: arrival reminder (info), sync conflict (warn), encoder unplug (critical), AI suggestion arrival (info), lock revocation failure (critical).
26.2 In-app inbox
A persistent inbox at /notifications with filters (severity, source, age, unread). Notifications can be acked, snoozed, or routed to another operator. A dashboard widget shows the top 5 unread.
26.3 Do-not-disturb
Per-operator DND hours (set in Settings); during DND only critical notifications surface as OS alerts; info and warn go to the inbox only. Tenant policy may override (e.g., always alert on lock failures).
27. Telemetry & Crash Reporting
27.1 Anonymized event telemetry
Main collects structured events:
- Lifecycle:
app.started,app.quit,update.installed,update.rolled_back - Performance:
cold_start_ms,sync_pull_ms,sync_push_ms,ai_infer_ms,db_query_ms_p95 - Reliability:
crash,unhandled_rejection,ipc.error,outbox.stuck - Workflow counts:
checkin.completed,checkout.completed,key.issued,cash.session.closed
Events are batched and sent to bff-backoffice-service's telemetry endpoint with the active tenantId + deviceId. Tenants can opt out of non-essential telemetry; operational telemetry (errors, sync status) is required.
27.2 Crash reporting
Electron's crashReporter is configured to upload native crashes to Cloud Errors at errors.melmastoon.ghasi.io. Symbol uploads happen at release time. Crashes carry the build SHA, OS, RSS at crash time, and the redacted last-N IPC calls (no payloads, only channel names + timestamps).
27.3 Local debug log
A rotating local log file at userData/logs/melmastoon-backoffice-YYYYMMDD.log (size-capped at 50 MB, 7-day rotation). The log is accessible via the Help menu → "Open log folder". Operators can attach it to a support ticket from the same menu (uploads via signed URL to file-storage-service with ticket reference).
28. Updates & Migrations
28.1 DB migrations
Migrations live at electron/db/migrations/<NNNN>-<slug>.sql. They are applied at startup inside a single transaction per migration. The migration runner:
- Acquires the DB lock (
busy_timeoutlong-poll). - Reads the current
schema_migrationstable. - For each pending migration: take a backup → apply → run
pragma integrity_check→ record inschema_migrations. - On any failure: restore from backup, surface a blocking dialog with retry / open-log / contact-support options.
28.2 Major-version migrations
For breaking schema changes (e.g., 2.x → 3.x), the user is shown an explicit pre-migration dialog: "This update changes the local database. We will back up first." The backup is kept for 30 days (size permitting); rollback is possible by reverting the binary.
28.3 Schema-migration test suite
Every migration has a contract test that loads a snapshot of the prior schema, runs the migration, and asserts the new shape + data correctness. The test suite runs in CI for every PR that touches electron/db/migrations/.
29. Multi-Tenant on Single Device
Phase 1: single tenant per install. The first-run wizard binds the device to one tenant; switching tenants requires a clean uninstall + reinstall.
Phase 2: chain operator override. Chain operators (a small number of users with chain.operator role) can install the app once and switch tenant scope via a tenant-switcher overlay. The local SQLite holds multiple tenant scopes in separate logical schemas (one SQLite file per tenant under userData/databases/<tenantId>/), each encrypted with its own key fragment derived from the same device key. The renderer's Zustand store carries the active tenantId; switching closes all open dialogs and re-mounts the route tree to prevent cross-tenant data leakage in component state.
Phase 3+: multi-property within tenant is already supported in Phase 1 via setProperty(...).
30. Testing Strategy
30.1 Unit
- Renderer: Vitest + jsdom for components, hooks, stores.
- Main: Vitest + Node 20 for repos, sync logic, encoder protocol, conflict resolution.
- Domain: pure-TS Vitest; no Electron, no SQLite.
30.2 Integration
- Spectron-style with Playwright Electron (
@playwright/test+electronlauncher). - Loads the real installer build, drives the renderer through the real
contextBridge, asserts main-process side effects (DB rows, outbox state, keychain calls — keychain is faked via a test adapter).
30.3 End-to-end flows
Full flows exercised:
- Login → dashboard → arrivals → check-in → key issuance (Wiegand mock) → folio update → check-out → invoice print.
- Walk-in → rate override → check-in → cash collection → close shift → manager step-up.
- Offline mode (network simulated by a Playwright route handler that fails all
bff-*calls): full check-in + folio + key issuance offline, then online → sync → assert server state matches. - Conflict scenario: simulate concurrent server change to a reservation note → push → assert conflict tray surfaces both versions.
- Auto-update flow: stage a new manifest → trigger update → install on quit → verify migration rollback path with a deliberately broken migration.
30.4 Chaos suite
Run nightly:
- Forced offline windows of 1 / 6 / 12 hours; replay flow on reconnect.
- Slow network (256 kbps / 1500 ms RTT) with random packet drop.
- Encoder unplug mid-issuance — assert rollback + clear operator messaging.
- Model integrity tamper — flip a byte in
anomaly-classifier-v1.onnx; assert AI worker refuses to load and main raises the blocking dialog. - Disk-full simulator (a tmpfs cap) — assert non-blocking warning, then blocking dialog.
- Power-loss simulator (
SIGKILLon main mid-transaction) — assert no partial state.
30.5 Security tests
- CSP violation harness — attempt inline script, remote font, eval — assert all blocked + logged.
- contextBridge surface enumeration — at runtime, walk
window.melmastoonand assert it matches the declared TS interface exactly (no additions, no missing methods). - SQLCipher key wipe — invoke
keychain.forgetAll()then attempt to open the DB → assert open fails withSQLITE_NOTADB. - DPoP replay — capture a request and replay → assert server rejects with
MELMASTOON.IAM.DPOP_INVALID. nodeIntegrationregression — CI lint asserts everyBrowserWindowconstructor matches the locked baseline.
30.6 Performance tests
- Cold-start benchmark on the CI runner (same hardware class as the baseline).
- SQLite microbenches (single-row read, list query, FTS, outbox enqueue).
- Bundle-size budget (renderer chunks); regression > 10 % fails the build.
31. Distribution & Onboarding
31.1 Distribution
The installer is downloaded from the tenant admin portal, scoped to the tenant. The portal page generates a short-lived signed URL (1 hour) tied to the platform-admin or tenant-owner account that requested it. Once installed, electron-updater takes over.
31.2 First-run wizard
The first-run wizard at /onboarding walks the operator through:
- Choose locale (defaults to OS locale).
- Tenant code (a short-code printed on the tenant admin portal:
TNT-AB12-CD34). - Owner credentials (one-time): the tenant-owner signs in to authorize device pairing. This is a hard requirement; no other role can pair a device.
- Device naming (e.g., "Front Desk – Lobby PC"); shown in the tenant device registry.
- Encoder pairing (if Wiegand encoder available): plug in the encoder; the worker auto-detects and probes; the operator labels each detected encoder.
- AI model verification: bundled ONNX models are verified against the signed manifest; on success, an "Edge AI ready" badge appears.
- First sync: full pull of the operator's tenant + property scope; progress bar with skip-to-background option.
- Done → main dashboard.
31.3 Re-pair
If the device is revoked (operator leaves, laptop reassigned), the next sync attempt returns 401 MELMASTOON.IDENTITY.DEVICE_NOT_BOUND and the app enters a read-only quarantine mode until re-pair. Re-pair re-runs steps 2–7; the local SQLite is rebuilt from a fresh pull.
32. Failure Modes
| Failure | User impact | Recovery path |
|---|---|---|
| SQLite corruption (PRAGMA integrity_check fails at startup) | App refuses to start; shows blocking dialog | Offer "Rebuild from server": rename corrupt file; create fresh DB; full pull. Outbox is lost — if outbox had unpushed mutations, the dialog warns and offers to upload the corrupt file for support recovery before rebuild. |
KMS / device-key loss (keytar entry missing) | Cannot open SQLCipher DB | Force re-pair flow; local DB is re-created from cloud; outbox is lost; user sees explicit warning |
| Auto-update failure (download fails or signature invalid) | Update skipped; current version continues running | Retry on next 6h check; manual "Check for updates" available; if version becomes too old (per bff-backoffice-service policy), a soft block prompts the user to download the installer manually from the tenant portal |
| ONNX model integrity fail | AI features blocked; blocking dialog | Reinstall the app (the installer carries fresh signed models); meanwhile, AI features show "Unavailable" |
| Encoder unplug mid-issuance | Single issuance fails | Worker rolls back the partial encode; outbox entry marked failed; operator is shown a clear "Re-plug encoder and retry" dialog with the affected reservation context preserved |
| Long-offline beyond grace | Local writes blocked; reads still work | Banner + countdown: "Reconnect within X to keep the app writable"; on grace breach, write actions are disabled until next successful sign-in |
| Sync conflict (auto-resolvable) | None visible | Resolved silently per policy; tally shown in sync tray |
| Sync conflict (HITL-required) | Operator must pick | Conflict tray surfaces both versions; operator decision is recorded in audit_log_local |
| Cash drawer variance > threshold | Close-shift action requires manager step-up | Step-up via WebAuthn / TOTP; manager identity + reason recorded |
| Disk full | Sync paused; writes blocked | Blocking dialog with link to userData and a "Compact + vacuum now" button; if compact fails, suggest dropping old partitions |
| Renderer crash | Window goes blank | Recovery overlay; main can re-create the window; in-flight outbox entries are not lost |
| Main process crash | App terminates | crashReporter uploads minidump; electron-updater re-launches on user action; no data loss because outbox is committed transactionally |
| CSP violation | Renderer blocks the offending resource | Telemetry event; main records the violation; security review on next release |
33. Anti-Patterns
These are never acceptable. CI guards every one:
nodeIntegration: trueon anyBrowserWindow. CI lint fails.remotemodule re-introduction. CI dep check fails.- Exposing raw SQL to renderer. No
db.exec(string)shaped IPC; only domain-shaped repo methods. - Bypassing the preload
contextBridgeby exposingipcRendererdirectly. CI grep on the preload file fails. - Embedding tenant secrets (long-lived API keys, payment-vendor secrets, lock-vendor SDK credentials) in renderer or in any source file. The desktop only ever holds short-lived per-session tokens issued by the cloud.
- Reading personal device contacts, photos, or filesystem outside
userData. The app does not request and never has access to user content drives. - Bundling vendor AI SDKs other than ONNX Runtime Node. CI dependency check fails.
- Using
child_processfrom main without a wrapped, audited helper. Directspawn/execis forbidden; the only sanctioned subprocess isutilityProcess.forkfor the AI worker. - Last-write-wins on money or inventory. Folio entries are append-only; inventory is server-authoritative on reconnect.
- Sending unredacted PII to telemetry or crash reports. Redaction is handled in main before any outbound send.
34. Future Considerations
- Multi-tenant switching (Phase 2): chain operators with a tenant-switcher overlay; one install serves multiple tenants with isolated SQLite files per tenant.
- Offline-only kiosk mode for arrivals (Phase 3): a stripped-down
BrowserWindowmode for self-check-in kiosks at lobby; reuses front-desk routes with a kiosk theme; supervisor unlock to exit kiosk. - Tablet sub-mode for housekeepers (Phase 3): the desktop app launched on a Windows / iPadOS tablet with a touch-optimized housekeeping board layout. Same Electron build, different launch flag.
- Voice transcription for hands-free updates (Phase 4): Whisper-tiny via ONNX Runtime Node lets housekeepers speak room status updates; transcription drives
db.room.setStatus+db.housekeeping.complete. - Local-LLM upgrades (Phase 4): Phi-3.5-mini-Q4 (or successor) bundled as an optional download for full offline message drafting and complex anomaly explanations. Same
ai.runCapabilitysurface; routing prefers local LLM when network is poor. - BLE-direct lock issuance (Phase 2): TTLock BLE direct via
@abandonware/noblein the lock encoder worker, bypassing TTLock cloud for in-range issuance — useful when the property has TTLock locks but no gateway. - Field-tech mode (Phase 4): a mode for a regional ops engineer carrying the desktop on a laptop to multiple tenants for support; multi-tenant + read-only-by-default + full audit of every action under their identity.
- Hardware attestation (Phase 4): Windows TPM / macOS Secure Enclave attestation in device pairing for higher-assurance tenants (chains, regulators).
- Background sync via OS task scheduler (Phase 3):
launchd/ Task Scheduler / systemd-user wakes a headless main process to keep sync moving even when the operator has the laptop closed (lid-open required for some peripherals; encoder is no-op while closed).
35. References
- 02 Enterprise Architecture — Layers, contexts, sync model, AI placement, GCP topology.
- 05 API Design — REST conventions, sync API (
/sync/v1/pull|push), error model, rate limits. - 06 Data Models — SQLite desktop schema, Firestore sync state, encryption posture.
- 07 Security, Compliance & Tenancy — STRIDE for desktop, Electron hardening (§15), keychain, SQLCipher.
- 08 AI Architecture —
ai-orchestrator-service, edge inference, provenance, HITL. - 09 Lock & Key Integration —
LockPort, vendor adapters, offline issuance, encoder protocol. - 10 Payments Architecture — Cash, card, MFS reconciliation; cash-drawer interactions.
- ADR-0001 Core Architecture & Tech Stack
- ADR-0002 Multi-Tenancy Model
- ADR-0003 Electron Offline-First Desktop — the decision document for this app.
- ADR-0004 Lock Integration Abstraction
- bff-backoffice-service · API_CONTRACTS — endpoints consumed by
window.melmastoon.*. - Standards · NAMING, Standards · ERROR_CODES
This document is the contract between the desktop engineering team and the rest of the Ghasi Melmastoon platform. Changes that affect the window.melmastoon surface, the sync protocol, the security posture, or the AI provenance contract require a corresponding ADR and a security review.