Skip to main content

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.

PersonaWhat they do in the desktop app
Front-Desk ClerkWalk-in capture, check-in / check-out, key issuance (mobile-key invite, PIN, encoded card), folio updates, cash collection
Housekeeping LeadAssign 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 TechTriage tickets, log parts/labor, close work orders, schedule preventive maintenance
General ManagerKPI dashboard, AI suggestions inbox, exception triage, override approvals (rate, refund, cash variance)
OwnerRead-only management view across one or more properties; financial roll-up; AI insights inbox
Finance / AdminCash-drawer reconciliation, folio audit, refund approval, regulatory exports (PDF/Excel/CSV)
Marketing ReviewerApprove 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:

ConcernElectronTauriVerdict
Lock vendor SDKs (TTLock, Salto, Assa Abloy)Node bindings, first-classRust crates, often community-maintained, laggingElectron — the vendor ecosystem is the constraint
Embedded SQLite ergonomicsbetter-sqlite3 synchronous; perfect for IPC bridgingrusqlite works but ergonomically further from our patternElectron
Edge AI parity with cloudONNX Runtime Node mirrors ai-orchestrator-service runtimetract / ort-rs smaller ecosystemElectron
Signed installers + auto-updateelectron-builder + electron-updater battle-tested for line-of-business appsYounger; Windows MSI signed-update is rougherElectron
Hiring profile in target marketsTS / Node — abundantRust — scarce in AF / TJ / IR / PKElectron
Peripherals (encoder USB / serial / receipt printer)serialport, node-hid, node-thermal-printer maturePossible but more frictionElectron
Code reuse with web + mobileShared TS domain, design tokens, i18n, AI provenance contractRust shell + JS renderer means two languagesElectron
Binary size~140 MB~10 MBTauri — but install size is not our constraint
Idle RAMHigher (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 (BrowserWindow factory, child windows for printers / preview, single-instance lock).
  • The full network surface: every fetch to bff-backoffice-service, iam-service.signIn, /sync/v1/pull|push, electron-updater channel.
  • The local SQLite database (better-sqlite3 opened 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, or electron itself.
  • Call fetch to lock-vendor or AI-vendor endpoints — those routes are owned by main.
  • Access the SQLite file or its key.
  • Open arbitrary URLs (shell.openExternal is 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

LoopCadence (link = good)Cadence (link = flaky)Cadence (offline)Conditions
Pullevery 30severy 90s with jittersuspendedper-aggregate cursor advances independently
Pushevery 5s when outbox non-emptyevery 15s with jittersuspendedup to 100 mutations per batch, ≤ 256 KiB compressed
Heartbeatevery 60severy 120sconfirms cursor liveness; updates last_heartbeat_at
Catch-upon transition offline → goodprioritized: reservations + folios first, then rooms, hkt, maintenance
Token refresh60s before access-JWT expiryDPoP-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). gzip fallback if the BFF reports no Brotli.
  • Data-saver mode: when setDataSaver(true), the engine skips staff_schedule, theme_cache, and ai_suggestion_history from pull, and defers AI suggestion drafting to next online window.
  • Aggregate prioritization: on offline → good transition, the catch-up runner pulls reservation, folio_draft, key_credential first, then room, 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_tickets
  • folios, folio_drafts, key_credentials_snapshot
  • guests, staff, staff_schedules
  • theme_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 (tokenizer unicode61 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_local are partitioned by month using a created_yyyymm shadow column + monthly attach/detach rotation. This keeps VACUUM cheap and lets us drop old partitions without rewriting the entire file.
  • A scheduled VACUUM runs 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 forceFullRebase for 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:

  1. Updates the local aggregate row (with a bumped version and merged vector_clock).
  2. Inserts an outbox row with the canonical mutation envelope (clientMutationId, aggregate, aggregateId, op, payload, baseVersion, vectorClock, created_at).
  3. 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

OSDefaultFallback
WindowsDirectML (dml) when GPU detectedCPU
macOS (Apple Silicon)CoreMLCPU
macOS (Intel)CPU
LinuxCUDA when nvidia-smi succeedsCPU

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

ItemStorageNotes
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 keyOS keychain (same device_private_key slot)Non-exportable on Windows (TPM-backed where available), macOS Secure Enclave when available, libsecret on Linux
SQLCipher key fragmentOS keychain (db_key_fragment slot)Combined with optional user passphrase via HKDF
Tenant CA cert chainApp-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

EventBehavior
Cold startCheck once if last check > 6h ago
Online pollingEvery 6 h while online
Manualupdate.checkForUpdates() from settings
Update foundDownload in background (resumable, throttled to 256 KiB/s by default — configurable)
Download completeNotify 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)
RollbackIf 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-updater verifies every artifact against the public key bundled in the installer. A signature mismatch aborts the install and records update.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:

  1. Takes a db.backup() snapshot to userData/db-backups/<version>.bak.
  2. Runs each pending migration inside a single SQLite transaction per migration.
  3. Verifies pragma integrity_check is ok.
  4. 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).

OSFormatsSigning
WindowsMSI + NSIS .exeAuthenticode (EV cert), SHA-256 timestamp; SmartScreen reputation seeded by EV
macOSuniversal .dmg (x86_64 + arm64)Apple Developer ID + hardened runtime + notarization (xcrun notarytool) + stapling
LinuxAppImage + .deb + .rpmGPG-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:

  1. User override (set in Settings; persisted in local_user_session).
  2. Operator profile preference (from iam-service).
  3. Property localeOverride (from property-service).
  4. Tenant defaultLocale (from tenant-service).
  5. Platform default en.

Phase-1 locales:

LocaleScriptDirectionPrimary marketFonts
psPashto (Arabic)RTLAfghanistanNoto Naskh Arabic, Vazirmatn fallback
fa-AFDari (Arabic)RTLAfghanistanVazirmatn, Noto Naskh Arabic fallback
fa-IRPersian (Arabic)RTLIranVazirmatn
arArabicRTLCross-regionNoto Naskh Arabic
tg-CyrlTajik (Cyrillic)LTRTajikistanNoto Sans, Noto Sans Cyrillic
enLatinLTRDefaultInter, Noto Sans

15.1 RTL handling

  • Layout uses CSS logical properties exclusively (margin-inline-start, padding-inline-end, inset-inline-start). No left/right literals 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-melmastoon direction-aware icon set.
  • Native menus are localized (Electron's Menu.buildFromTemplate accepts 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 from tenant-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+K global command palette; Ctrl/Cmd+N new walk-in; Ctrl/Cmd+I check-in selected reservation; Ctrl/Cmd+O check-out; Ctrl/Cmd+H housekeeping board; Ctrl/Cmd+L issue lock key; Ctrl/Cmd+Shift+P print 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.

MetricTarget
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

  1. Operator clicks Check In.
  2. 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).
  3. Confirm room assignment (auto-assigned by housekeeping-service if the room is clean and matches type; otherwise operator picks from available).
  4. Choose key delivery: mobile-key invite (SMS), PIN, encoded card (Wiegand encoder). The choice depends on lock.listDevices results for the property.
  5. Click Issue Keylock.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.
  6. 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

  1. Display folio summary + final balance.
  2. Capture remaining payment (cash, card via terminal, MFS reference).
  3. Revoke key via lock.revoke(...).
  4. Print invoice / guest statement (printer.printInvoice(...)).
  5. Mark reservation checked_out; transition the room status to dirty; create a housekeeping_task of type turnover with 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:

StatusColor (light)Color (dark)Shape
clean--ms-green-300--ms-green-700filled circle
dirty--ms-amber-300--ms-amber-700half circle
inspected--ms-blue-300--ms-blue-700filled square
OOO (Out of Order)--ms-red-300--ms-red-700hollow circle with cross
OOS (Out of Service)--ms-grey-400--ms-grey-600hatched 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).

WidgetSourceSkeletonLazy
Today's KPIs (occupancy, ADR, RevPAR, pickup)bff-backoffice-service GET /dashboard (cached locally for 60s; recomputed from local SQLite if offline)yesno
Arrivals (next 6h)local SQLite + SSE feedyesyes
Departures (next 6h)local SQLite + SSE feedyesyes
In-house guestslocal SQLiteyesyes
AI suggestions inboxlocal ai_suggestions table + SSEyesyes
Alerts inboxlocal + SSEyesyes
Sync statuslive from events.sync.statusnono
Cash drawer statuslocal cash_sessionsyesyes
Lock event tapeevents.lock.eventyesyes
Housekeeping board snapshotlocal SQLiteyesyes

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 ago or Suggested locally · anomaly-classifier v1.3.0 · CoreML)
  • Action chips: Accept, Modify, Reject (each records a decisions row)
  • 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:

  1. Enters opening cash count (MoneyMicro per currency — multi-currency drawers supported).
  2. Enters their PIN.
  3. db.cashDrawer.open(opening, pin) creates a cash_sessions row in state = 'open', references the operator and the shift.
  4. 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:

  1. The clerk enters the closing cash count.
  2. The system computes expected vs counted; variance is highlighted.
  3. Two-staff sign-off is required: a second staff member enters their PIN to co-sign the close.
  4. 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:

SeverityOS soundAuto-dismiss
infonone5 s
warnOS default10 s
criticalOS critical alertsticky 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:

  1. Acquires the DB lock (busy_timeout long-poll).
  2. Reads the current schema_migrations table.
  3. For each pending migration: take a backup → apply → run pragma integrity_check → record in schema_migrations.
  4. 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 + electron launcher).
  • 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:

  1. Login → dashboard → arrivals → check-in → key issuance (Wiegand mock) → folio update → check-out → invoice print.
  2. Walk-in → rate override → check-in → cash collection → close shift → manager step-up.
  3. 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.
  4. Conflict scenario: simulate concurrent server change to a reservation note → push → assert conflict tray surfaces both versions.
  5. 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 (SIGKILL on 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.melmastoon and 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 with SQLITE_NOTADB.
  • DPoP replay — capture a request and replay → assert server rejects with MELMASTOON.IAM.DPOP_INVALID.
  • nodeIntegration regression — CI lint asserts every BrowserWindow constructor 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:

  1. Choose locale (defaults to OS locale).
  2. Tenant code (a short-code printed on the tenant admin portal: TNT-AB12-CD34).
  3. 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.
  4. Device naming (e.g., "Front Desk – Lobby PC"); shown in the tenant device registry.
  5. Encoder pairing (if Wiegand encoder available): plug in the encoder; the worker auto-detects and probes; the operator labels each detected encoder.
  6. AI model verification: bundled ONNX models are verified against the signed manifest; on success, an "Edge AI ready" badge appears.
  7. First sync: full pull of the operator's tenant + property scope; progress bar with skip-to-background option.
  8. 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

FailureUser impactRecovery path
SQLite corruption (PRAGMA integrity_check fails at startup)App refuses to start; shows blocking dialogOffer "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 DBForce 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 runningRetry 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 failAI features blocked; blocking dialogReinstall the app (the installer carries fresh signed models); meanwhile, AI features show "Unavailable"
Encoder unplug mid-issuanceSingle issuance failsWorker 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 graceLocal writes blocked; reads still workBanner + 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 visibleResolved silently per policy; tally shown in sync tray
Sync conflict (HITL-required)Operator must pickConflict tray surfaces both versions; operator decision is recorded in audit_log_local
Cash drawer variance > thresholdClose-shift action requires manager step-upStep-up via WebAuthn / TOTP; manager identity + reason recorded
Disk fullSync paused; writes blockedBlocking dialog with link to userData and a "Compact + vacuum now" button; if compact fails, suggest dropping old partitions
Renderer crashWindow goes blankRecovery overlay; main can re-create the window; in-flight outbox entries are not lost
Main process crashApp terminatescrashReporter uploads minidump; electron-updater re-launches on user action; no data loss because outbox is committed transactionally
CSP violationRenderer blocks the offending resourceTelemetry event; main records the violation; security review on next release

33. Anti-Patterns

These are never acceptable. CI guards every one:

  • nodeIntegration: true on any BrowserWindow. CI lint fails.
  • remote module 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 contextBridge by exposing ipcRenderer directly. 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_process from main without a wrapped, audited helper. Direct spawn / exec is forbidden; the only sanctioned subprocess is utilityProcess.fork for 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 BrowserWindow mode 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.runCapability surface; routing prefers local LLM when network is poor.
  • BLE-direct lock issuance (Phase 2): TTLock BLE direct via @abandonware/noble in 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


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.