Skip to main content

09 — Lock & Key Integration

Companion: 02 Enterprise Architecture §4.10 · ADR-0004 Lock Abstraction · ADR-0003 Electron Offline-First · 04 Event-Driven Architecture · 07 Security & Tenancy · 10 Payments Architecture · 12 Desktop Spec · Error Codes — LOCK

This document is the canonical specification for how Ghasi Melmastoon issues, updates, suspends, revokes, and audits guest and staff keys across heterogeneous lock hardware. It defines the LockPort interface, the per-vendor adapter contracts, the key lifecycle, the booking-driven sagas, the offline issuance path on the Electron desktop, and the security and compliance posture for the most physically consequential integration in the platform.

The implementation lives in services/lock-integration-service/. No other service may import a vendor lock SDK; CI dependency analysis enforces this rule (see ADR-0004 §Compliance).


1. Why Lock Integration Matters

Lock and key integration is not a "nice to have" feature for Melmastoon. It is the highest-leverage operational differentiator in our target markets and the most physically consequential surface we operate.

1.1 Keyless check-in as a market differentiator

In the markets we enter first — Afghanistan, Tajikistan, Iran, then GCC and South Asia — independent and small-chain hotels still issue mechanical keys or manually-coded RFID cards from a paper roster. Front-desk staff hand-encode cards with date stamps written on tape. Lost cards are routine and cost USD 8–25 each to re-issue, with the lock often re-keyed manually. A guest arriving at 02:00 typically waits 5–15 minutes while the night clerk wakes, finds the key, encodes the card, and signs the paper roster.

Melmastoon's lock integration eliminates that:

  • Mobile-key delivery for guests with smartphones — the key arrives in the booking confirmation push notification or email; the guest walks past reception straight to the room (subject to tenant policy on ID verification).
  • Pre-issued PIN code for guests without smartphones — printed on the booking confirmation, valid for the stay window only.
  • Encoded RFID card at front-desk for guests who prefer the legacy artifact — the card is encoded on the property's existing encoder via the desktop's local adapter.
  • Remote revocation the moment a guest cancels, checks out early, or is flagged for security review — no re-keying, no card retrieval, no risk window.

1.2 Quantified value

LeverStatus quoWith Melmastoon
Avg front-desk time per check-in4–8 min30–90 sec (or zero with mobile key)
Lost-card cost per stay (5%–10% of stays)USD 0.40–2.50USD 0.05–0.30 (re-issue is software)
Time-to-revoke a missing card30+ min (re-key)< 5 sec (saga revoke)
Audit trail completenessPaper / inconsistentImmutable per-action log, 7 yr retention
Late-night staffing requirementMandatoryOptional (mobile/PIN delivery)

For an 8-room guesthouse turning 20 stays/week, the staffing reduction alone offsets the entire Melmastoon platform fee at the Starter tier within the first month.

1.3 Safety and risk posture

  • Remote revocation on cancellation removes the physical-access risk window.
  • Time-bound credentials — every key has a hard validFrom/validUntil enforced at the lock; expired keys do not open the door even if the credential bytes leak.
  • Per-room scoping — guest keys open only assigned rooms; staff master keys are scope-bounded by role and shift.
  • Immutable audit of every issue, update, suspend, revoke — included in the audit-service daily Merkle anchor.
  • Vendor isolation — a leaked vendor API key never sees other tenants' data because vendor credentials live in a per-tenant, per-property namespace inside Secret Manager.

2. Vendor Landscape

We classify vendors by phase. Phase 1 vendors must ship adapter, contract tests, sandbox e2e, and runbook before MVP cutover. Phase 2+ vendors are scoped but not blocked into MVP.

2.1 Comparison Matrix

VendorConnectivityKey typesCloud / On-premTarget market presenceSDK languageCertification postureCost classPhase
TTLockBLE + cloud API; gateway for remote opsmobile_app, pin_code, rfid_cardCloud-first; optional gatewayStrong in Asia, Pakistan, Iran, Afghanistan, India; growing in MENANode, Java, .NET, mobile SDKsNone formal; ISO 27001 vendor claimLow (USD 30–80/lock)Phase 1
Salto SVN (XS4 line)XS4 cloud API + on-prem SVN connector for legacy installsrfid_card (primary), mobile_app, pin_code (subset)Hybrid — cloud + on-prem connectorStrong in mid-market chains globally; established in GCC, EUREST + on-prem connector binaryUL, EN 14846, ISO/IEC 27001Mid (USD 200–500/lock)Phase 1
Assa Abloy VostioCloud-first REST; Aperio for wireless online; Visionline for legacy bridgemobile_app (primary), rfid_card, nfc_tagCloud (Vostio); on-prem (Visionline bridge)Enterprise hospitality globally; chainsREST + Vostio SDKUL, EN, ISO/IEC 27001, SOC 2High (USD 350–800/lock)Phase 1
Generic Wiegand / RFIDEncoder over USB or RS-232/485 serial; offline-only by designrfid_card, nfc_tag (encoder-dependent)On-prem only — encoder lives at front deskLong-tail of legacy installs everywhereVendor-specific HID/serial protocols, wrapped by node-hid / serialportNoneVery low (existing hardware)Phase 1
DormakabaCloud (Ambiance) + on-prem (Saflok)rfid_card, mobile_appHybridStrong enterprise EU + USREST + .NET SDK (cloud); proprietary (Saflok)UL, EN, ISO/IEC 27001HighPhase 3
Kaba (legacy)Serial / proprietary protocol via on-site encoderrfid_cardOn-prem onlyLong-tail legacy globallyProprietaryNoneVery low (legacy)Phase 4

2.2 Phase rollout commitment

PhaseVendors shippedRationale
MVP (Phase 1)TTLock, Salto SVN, Assa Abloy Vostio, Generic WiegandCovers >90% of target-market hardware: TTLock for emerging markets, Salto for mid-market chains, Vostio for enterprise, Wiegand for legacy.
Phase 2Hardening — extended capability flags, BLE gateway support for TTLock, Salto offline-validity propagation refinementNo new vendors; deepen the four already shipped.
Phase 3Dormakaba (Ambiance + Saflok)Required for enterprise EU/US chain expansion.
Phase 4Kaba legacy bridgeLong-tail acquisition target only — adapter built when first paying tenant requires it.

2.3 What we explicitly do not support

  • Consumer-grade smart locks (August, Yale Linus, etc.) — different threat model, different SLA, different audit posture; out of scope.
  • Lock vendors that require a permanent outbound tunnel from the property to a vendor cloud we cannot inspect — security posture incompatible.

3. Architecture Overview

The lock subsystem is shaped by three forces: vendor heterogeneity, the offline-first desktop, and credential isolation. The architecture below resolves all three by routing every lock action through a single port behind a single saga, with vendor adapters living either in the cloud service or in the Electron main process — never in the renderer, never in another microservice.

┌─────────────────────────────────────────────────────────────────────────────────┐
│ GCP — Cloud Run │
│ │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ lock-integration-service │ │
│ │ │ │
│ │ Presentation: REST controllers + vendor webhook receivers │ │
│ │ Application: key-lifecycle saga + use-cases + ports │ │
│ │ Domain: KeyCredential aggregate, lifecycle state machine │ │
│ │ Infrastructure: │ │
│ │ ┌──────────────────────────────────────────────────────────┐ │ │
│ │ │ LockPort (interface) │ │ │
│ │ └──────┬─────────────┬──────────────┬───────────────┬─────┘ │ │
│ │ │ │ │ │ │ │
│ │ ┌──────▼─────┐ ┌─────▼──────┐ ┌─────▼──────┐ ┌──────▼──────┐ │ │
│ │ │ TTLock │ │ Salto SVN │ │ Assa Abloy │ │ CloudProxy │ │ │
│ │ │ adapter │ │ adapter │ │ Vostio │ │ for Wiegand │ │ │
│ │ │ (BLE+cloud)│ │ (XS4 + │ │ adapter │ │ (relays to │ │ │
│ │ │ │ │ on-prem │ │ (cloud) │ │ Electron) │ │ │
│ │ │ │ │ connector) │ │ │ │ │ │ │
│ │ └────────────┘ └────────────┘ └────────────┘ └─────────────┘ │ │
│ │ │ │
│ │ Data: per-property vendor config (encrypted, separate KMS key) │ │
│ │ KeyCredential ledger │ │
│ │ Saga state │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
│ ▲ │
│ │ reservation.* events (Pub/Sub) │
│ │ payment.captured.v1 (Pub/Sub) │
│ │ │
│ ┌──────────┴───────────┐ │
│ │ reservation-service │ │
│ └──────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────────┘

│ HTTPS (sync push/pull, REST)

┌─────────────────────────────────▼───────────────────────────────────────────────┐
│ Electron Desktop (Backoffice) │
│ │
│ Renderer (Chromium) ──IPC──> Main Process (Node 20) │
│ contextIsolation: true ─ owns vendor SDKs (USB/serial)│
│ nodeIntegration: false ─ owns LockPort local adapter │
│ no lock SDK, no USB ─ owns SQLite + outbox │
│ ─ owns ONNX Runtime │
│ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ Local LockPort adapter (USB/serial encoder, BLE-in-range TTLock) │ │
│ │ Issues provisional KeyCredentials when cloud unreachable. │ │
│ │ Marks every local issue with `provisional: true`. │ │
│ │ Queues `lock.key.issued.local.v1` to local outbox. │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────────────┘

3.1 Where each adapter runs

AdapterCloud (lock-integration-service)Electron main
TTLock — cloud-only opsYes (default for remote issue/revoke when cloud is healthy)Yes (BLE direct when cloud unreachable and lock in BLE range)
Salto SVN — cloud RESTYes (default)No
Salto SVN — on-prem connectorNo (connector is a Salto-deployed binary at the property; cloud adapter calls it via the property's tunnel)No
Assa Abloy VostioYes (cloud-only)No
Generic Wiegand / RFID encoderCloud cannot reach a USB device; cloud adapter is a CloudProxy that delegates to the desktop's local adapter via an authenticated WebSocket channel maintained by the desktop.Yes (owns the actual USB/serial handle)

3.2 Renderer never touches lock APIs

This is non-negotiable and follows from ADR-0003:

  • The renderer process has contextIsolation: true, nodeIntegration: false, sandbox: true.
  • The renderer asks for lock actions via the narrow window.melmastoon.locks.* contextBridge surface (e.g. requestIssueKey(reservationId)).
  • The Electron main process receives the IPC call, validates against the bound user's role, and either forwards to lock-integration-service (online path) or invokes the local LockPort adapter (offline path).
  • Vendor SDKs that touch USB, serial, or BLE are imported only in the main process. CI checks the renderer bundle for any vendor SDK or node-hid/serialport/@abandonware/noble import and fails the build.

4. The LockPort Interface

LockPort is the only interface anything inside or outside lock-integration-service knows about. Every consumer (the saga, the Electron main process, the contract tests) depends on this port. The shape below is the canonical TypeScript declaration; the real file lives at services/lock-integration-service/src/application/ports/lock.port.ts.

import type {
TenantId,
PropertyId,
ReservationId,
RoomId,
GuestId,
UserId,
ISODate,
Brand,
} from '@ghasi/shared/types';

export type KeyCredentialId = Brand<string, 'KeyCredentialId'>;
export type LockDeviceId = Brand<string, 'LockDeviceId'>;

export type KeyCredentialKind =
| 'mobile_app' // signed token consumed by the tenant booking app or vendor app
| 'pin_code' // numeric PIN entered at the door keypad
| 'rfid_card' // physical card encoded at the property's encoder
| 'qr_code' // QR scanned at door reader (vendor support varies)
| 'nfc_tag'; // NFC token (smartphone tap or sticker)

export type SuspendReason =
| 'no_show'
| 'fraud_review'
| 'overdue_payment'
| 'manual';

export type RevokeReason =
| 'checkout'
| 'cancellation'
| 'security'
| 'lost'
| 'replaced';

export type VendorCode =
| 'ttlock'
| 'salto'
| 'assa-abloy'
| 'generic-wiegand';

export interface IssueInput {
tenantId: TenantId;
propertyId: PropertyId;
reservationId: ReservationId;
rooms: RoomId[];
validFrom: ISODate;
validUntil: ISODate;
guest: {
id: GuestId;
displayName: string;
contact?: { email?: string; phoneE164?: string };
};
preferredKinds: KeyCredentialKind[]; // ordered preference; adapter picks first supported
scope?: {
floors?: string[];
areas?: ('lobby' | 'gym' | 'pool' | 'spa' | 'parking')[];
};
idempotencyKey: string; // stable per saga step
requestedBy?: UserId; // staff who initiated, if any
channelHint?: 'mobile' | 'desk' | 'kiosk';
}

export interface IssuedCredential {
keyCredentialId: KeyCredentialId;
kind: KeyCredentialKind;
validFrom: ISODate;
validUntil: ISODate;
rooms: RoomId[];
delivery: {
artifact?: { type: 'pin'; value: string } // PIN to send to guest
| { type: 'mobile_token'; opaqueRef: string } // ref the booking app exchanges
| { type: 'qr'; opaqueRef: string }
| { type: 'nfc'; opaqueRef: string }
| { type: 'rfid_card_ready_at'; deviceId: LockDeviceId };
deepLink?: string; // tenant booking app deep link
};
vendor: VendorCode;
vendorRef: string; // opaque vendor identifier, never logged
provisional: boolean; // true if issued by Electron offline adapter
issuedAt: ISODate;
}

export interface IssueResult {
credential: IssuedCredential;
warnings?: { code: string; detail: string }[];
}

export interface UpdateInput {
tenantId: TenantId;
keyCredentialId: KeyCredentialId;
rooms?: RoomId[];
validUntil?: ISODate;
scope?: IssueInput['scope'];
idempotencyKey: string;
}

export interface UpdateResult {
credential: IssuedCredential;
}

export interface LockDevice {
deviceId: LockDeviceId;
propertyId: PropertyId;
vendor: VendorCode;
label: string; // human label e.g. "Room 204 main door"
rooms: RoomId[]; // mapped rooms (often 1, sometimes shared doors)
online: boolean;
battery?: { percent: number; lowThresholdPercent: number };
firmware?: string;
lastSeenAt: ISODate;
capabilities: LockCapabilities;
}

export interface LockCapabilities {
mobileKey: boolean;
cardEncoding: boolean;
pin: boolean;
qr: boolean;
nfc: boolean;
remoteRevoke: boolean;
remoteIssue: boolean;
offlineIssuance: boolean;
scopeFloors: boolean;
scopeAreas: boolean;
}

export interface DeviceHealth {
deviceId: LockDeviceId;
online: boolean;
battery?: { percent: number };
clockSkewMs?: number; // device clock vs server
lastSyncAt?: ISODate;
warnings: { code: string; detail: string }[];
}

export type LockError =
| { code: 'MELMASTOON.LOCK.VENDOR_UNREACHABLE'; retriable: true; vendor: VendorCode }
| { code: 'MELMASTOON.LOCK.KEY_ISSUE_FAILED'; retriable: true; vendor: VendorCode; vendorMessage?: string }
| { code: 'MELMASTOON.LOCK.KEY_REVOKE_FAILED'; retriable: true; vendor: VendorCode; vendorMessage?: string }
| { code: 'MELMASTOON.LOCK.DEVICE_NOT_PAIRED'; retriable: false; deviceId?: LockDeviceId }
| { code: 'MELMASTOON.LOCK.CREDENTIAL_EXPIRED'; retriable: false; keyCredentialId: KeyCredentialId }
| { code: 'MELMASTOON.LOCK.CARD_ENCODER_OFFLINE'; retriable: true; deviceId: LockDeviceId };

export interface LockPort {
issueCredential(input: IssueInput): Promise<IssueResult>;
updateCredential(input: UpdateInput): Promise<UpdateResult>;
revokeCredential(credentialId: KeyCredentialId, reason: RevokeReason, idempotencyKey: string): Promise<void>;
suspendCredential(credentialId: KeyCredentialId, reason: SuspendReason, idempotencyKey: string): Promise<void>;
unsuspendCredential(credentialId: KeyCredentialId, idempotencyKey: string): Promise<void>;
listDevices(propertyId: PropertyId): Promise<LockDevice[]>;
healthCheck(deviceId: LockDeviceId): Promise<DeviceHealth>;
describeAdapter(): { vendor: VendorCode; capabilities: LockCapabilities };
}

4.1 Invariants

  • Every method takes either idempotencyKey directly or works on an idempotent identifier (keyCredentialId).
  • No method returns vendor SDK types. The adapter translates all vendor errors to LockError.
  • IssuedCredential.vendorRef is opaque to the platform; only the originating adapter understands it. It is never logged, never returned via any other service's API, and never replicated to the desktop SQLite.
  • provisional: true is set only by the Electron offline path. The cloud reconciler clears it (or re-issues) on next sync.

5. Per-Vendor Adapter Notes

This section captures the adapter shape, auth flow, supported key kinds, latency budget, vendor-specific failure modes, and a reference sequence diagram per Phase-1 vendor. Each adapter lives at services/lock-integration-service/src/infrastructure/adapters/<vendor>/ and may only be imported by lock-integration-service (or the Electron main for the local Wiegand adapter).

5.1 TTLock (BLE + cloud)

PropertyValue
ConnectivityTTLock cloud REST (default); BLE direct via gateway or via Electron main
Auth flowOAuth2 client_credentials per tenant; per-property gateway token issued by tenant onboarding
Key kindsmobile_app (signed eKey), pin_code, rfid_card (subset of locks)
Latency targetCloud issue p95 < 1.2 s; BLE direct p95 < 3 s
Offline issuanceBLE direct from Electron main when in range; provisional
Vendor-specific failure modesGateway offline → fallback to BLE direct or PIN; eKey clock drift on lock; rate-limit at 100 ops/min per app

5.1.1 TTLock — issue sequence (cloud path)

saga ttlock-adapter TTLock cloud lock device
│ │ │ │
│ issueCredential() │ │ │
├─────────────────────────►│ │ │
│ │ POST /v3/key/get │ │
│ ├────────────────────►│ │
│ │ │ BLE push via gw │
│ │ ├────────────────────►│
│ │ │ ack │
│ │ │◄────────────────────┤
│ │ { eKeyId, ... } │ │
│ │◄────────────────────┤ │
│ IssueResult │ │ │
│◄─────────────────────────┤ │ │
│ │ │ │
│ emit lock.key.issued.v1 │ │ │
│ │ │ │

5.1.2 TTLock — issue sequence (BLE direct via Electron main, offline)

desktop-renderer desktop-main ttlock-local-adapter lock device (BLE)
│ │ │ │
│ requestIssueKey()│ │ │
├─────────────────►│ │ │
│ │ issue (provisional) │ │
│ ├────────────────────►│ │
│ │ │ BLE provision │
│ │ ├──────────────────────►│
│ │ │ ack │
│ │ │◄──────────────────────┤
│ │ IssueResult │ │
│ │ (provisional: true) │ │
│ │◄────────────────────┤ │
│ result │ │ │
│◄─────────────────┤ │ │
│ │ enqueue │ │
│ │ lock.key.issued │ │
│ │ .local.v1 outbox │ │

5.2 Salto SVN (XS4)

PropertyValue
ConnectivityXS4 cloud REST + on-prem connector binary (Salto-deployed at the property)
Auth flowAPI key per tenant (rotated 90 d); on-prem connector authenticated via mTLS to the cloud REST
Key kindsrfid_card (primary), mobile_app (Salto KS subset), pin_code (subset)
Latency targetCloud issue p95 < 2 s; on-prem connector adds ≤ 500 ms
Offline issuanceLimited — Salto's offline-validity propagation graph allows time-bound cards to keep working even if the lock is offline
Vendor-specific failure modesOn-prem connector crash → cloud falls back to direct lock-cloud only for online locks; SVN propagation lag for newly-issued offline keys

5.2.1 Salto — issue sequence

saga salto-adapter XS4 cloud on-prem connector SVN lock
│ │ │ │ │
│ issueCredential() │ │ │ │
├────────────────────►│ │ │ │
│ │ POST /api/keys │ │ │
│ ├──────────────────►│ │ │
│ │ │ relay (mTLS) │ │
│ │ ├──────────────────►│ │
│ │ │ │ encode card │
│ │ │ ├──────────────────►│
│ │ │ │ ack │
│ │ │ │◄──────────────────┤
│ │ { cardRef, ...} │ │ │
│ │◄──────────────────┤ │ │
│ IssueResult │ │ │ │
│◄────────────────────┤ │ │ │

5.3 Assa Abloy Vostio

PropertyValue
ConnectivityCloud REST (Vostio Access Management API)
Auth flowOAuth2 client_credentials per tenant; tenant-specific Vostio environment
Key kindsmobile_app (Vostio Mobile Access), rfid_card, nfc_tag
Latency targetCloud issue p95 < 1.5 s
Offline issuanceNot supported by vendor; offline path uses local Wiegand encoder if available
Vendor-specific failure modesVostio environment maintenance windows (vendor-published); rate limit at 30 req/s per environment

5.3.1 Vostio — issue sequence

saga vostio-adapter Vostio cloud lock device
│ │ │ │
│ issueCredential() │ │ │
├──────────────────────►│ │ │
│ │ POST /access-grants │ │
│ ├─────────────────────►│ │
│ │ │ provision (Aperio / │
│ │ │ Visionline bridge) │
│ │ ├─────────────────────►│
│ │ │ ack │
│ │ │◄─────────────────────┤
│ │ { grantId, mobile } │ │
│ │◄─────────────────────┤ │
│ IssueResult │ │ │
│◄──────────────────────┤ │ │

5.4 Generic Wiegand / RFID encoder

PropertyValue
ConnectivityUSB HID or RS-232/485 serial via Electron main; cloud talks to desktop via authenticated WebSocket relay
Auth flowEncoder-local (no vendor cloud); device pairing via desktop onboarding wizard
Key kindsrfid_card, nfc_tag (encoder-dependent)
Latency targetEncode p95 < 2.5 s on USB
Offline issuanceFirst-class — this adapter is fundamentally offline
Vendor-specific failure modesEncoder USB disconnect; corrupted card on encode; wrong MIFARE sector configuration

5.4.1 Wiegand — issue sequence (cloud-initiated, served by desktop)

saga cloud-proxy ws-relay desktop-main wiegand encoder
│ │ │ │ │
│ issueCredential() │ │ │ │
├────────────────────►│ │ │ │
│ │ relay job │ │ │
│ ├─────────────────►│ │ │
│ │ │ deliver │ │
│ │ ├────────────────►│ │
│ │ │ │ encode │
│ │ │ ├─────────────────►│
│ │ │ │ ack │
│ │ │ │◄─────────────────┤
│ │ │ result │ │
│ │ │◄────────────────┤ │
│ │ result │ │ │
│ │◄─────────────────┤ │ │
│ IssueResult │ │ │ │
│◄────────────────────┤ │ │ │

If the desktop is offline, the cloud proxy returns MELMASTOON.LOCK.CARD_ENCODER_OFFLINE and the saga falls back to the desktop-initiated offline path described in §9.


6. Key Lifecycle

6.1 State machine

(saga starts)


┌───────────┐ accepted by ┌───────────┐ vendor ┌──────────┐
│ requested │ ───vendor────────►│ pending │ ───provisioned──►│ active │
└─────┬─────┘ └─────┬─────┘ └────┬─────┘
│ │ │
│ vendor refusal │ vendor timeout │
│ │ exceeded retry budget │
▼ ▼ │
┌───────────┐ ┌───────────┐ │
│ failed │ │ failed │ │
└───────────┘ └───────────┘ │

┌──────────────────────────────────────────────┤
│ │
│ suspend (no_show / fraud / overdue / manual) │
▼ │
┌───────────┐ │
│ suspended │ ◄──────── unsuspend ───────────────────┤
└─────┬─────┘ │
│ │
│ revoke │
▼ │
┌───────────┐ │
│ revoked │ ◄──── revoke (checkout / cancel / ─────┘
└───────────┘ security / lost / replaced)

6.2 Invariants

  • No skipping forward. A credential cannot reach active without traversing pending. The saga waits for explicit vendor confirmation.
  • Revoke is terminal. A revoked credential is never reactivated; re-issue creates a new KeyCredentialId.
  • Suspend is reversible. Only suspended → active is allowed via unsuspend.
  • Active window is enforced at the lock. Even if the platform fails to revoke, validUntil causes the lock to refuse the credential.
  • Provisional credentials reconcile or revoke. A credential issued offline with provisional: true is reconciled by the cloud saga on next sync; if the saga's view diverges, the provisional is revoked and a new credential issued.
  • Every transition emits an event. No silent state changes; every transition writes to the outbox (cloud) or local outbox (desktop).

6.3 Reservation-driven transitions

Reservation eventKeyCredential action
reservation.confirmed.v1Create requested; saga advances through pending → active
reservation.dates_changed.v1updateCredential — extend or contract validUntil; if room changed, also rotate rooms
reservation.no_show.v1suspendCredential(reason: 'no_show')
reservation.early_checkout.v1revokeCredential(reason: 'checkout')
reservation.checkout.v1revokeCredential(reason: 'checkout')
reservation.cancelled.v1revokeCredential(reason: 'cancellation')
reservation.fraud_flagged.v1suspendCredential(reason: 'fraud_review')

7. Saga: Issue on Confirmed Booking

The "issue on confirm" saga is the canonical happy path. The full booking saga (inventory → payment → key → notify) is documented in 02 Enterprise Architecture §7.3; the slice below is the lock subsystem's view.

7.1 Sequence

reservation-service Pub/Sub lock-integration-service vendor adapter notification-service
│ │ │ │ │
│ reservation. │ │ │ │
│ confirmed.v1 │ │ │ │
├─────────────────►│ │ │ │
│ │ │ │ │
│ │ deliver │ │ │
│ ├──────────────────────►│ │ │
│ │ │ saga.start(reservationId) │ │
│ │ │ persist KeyCredential │ │
│ │ │ (state=requested) │ │
│ │ │ │ │
│ │ │ LockPort.issueCredential() │ │
│ │ ├─────────────────────────────►│ │
│ │ │ │ vendor SDK call │
│ │ │ │ ────────────────────► │
│ │ │ │ ack / artifact │
│ │ │ │ ◄──────────────────── │
│ │ │ IssueResult │ │
│ │ │◄─────────────────────────────┤ │
│ │ │ persist: state=active │ │
│ │ │ vendorRef, validity, kind │ │
│ │ │ outbox: lock.credential │ │
│ │ │ .issued.v1 │ │
│ │ lock.credential │ │ │
│ │ .issued.v1 │ │ │
│ │◄──────────────────────┤ │ │
│ │ │ │ │
│ │ deliver │ │ │
│ ├─────────────────────────────────────────────────────────────────────────────►│
│ │ │ │ │ pick template by kind:
│ │ │ │ │ mobile → push + email
│ │ │ │ │ pin → email + SMS
│ │ │ │ │ rfid → desk pickup note
│ │ │ │ │
│ │ │ │ │ deliver

7.2 Failure paths and compensations

FailureDetectionCompensation
Vendor returns LockError.VENDOR_UNREACHABLEAdapterSaga retries with exponential backoff (max 5, jitter); after exhaustion, advances credential to failed and emits lock.vendor.error.v1. Reservation stays confirmed; manual override path opens a "manual key" in the desktop.
Vendor returns KEY_ISSUE_FAILED permanentlyAdapterSame as above; no retry.
lock.credential.issued.v1 published but notification failsNotification subscriber DLQNotification replay; no compensation in lock subsystem (key already issued).
reservation.cancelled.v1 arrives during issue sagaSaga stateIf credential not yet active, saga aborts and emits lock.credential.failed.v1. If active, saga immediately starts the revoke sub-saga.
Duplicate reservation.confirmed.v1 (Pub/Sub at-least-once)Idempotency key on saga stepSecond delivery is a no-op; returns the existing KeyCredentialId.
Adapter crash mid-call (cloud Run instance dies)Saga state outlives instanceOn consumer redelivery, saga resumes from last persisted step using idempotencyKey to ensure vendor sees one logical request.

7.3 Saga step idempotency

Every saga step keys idempotency by sha256(reservationId + step + version). Vendor adapters are required to honor idempotencyKey — the TTLock adapter sends it as clientNonce, the Salto adapter as X-Idempotency-Key, the Vostio adapter as Idempotency-Key, the Wiegand local adapter persists the key in its local SQLite to deduplicate.


8. Saga: Revoke on Checkout

8.1 Sequence — online path

reservation-service Pub/Sub lock-integration-service vendor adapter
│ │ │ │
│ reservation. │ │ │
│ checkout.v1 │ │ │
├─────────────────►│ │ │
│ │ deliver │ │
│ ├──────────────────────►│ │
│ │ │ load KeyCredential(s) │
│ │ │ for reservationId │
│ │ │ │
│ │ │ LockPort.revokeCredential() │
│ │ ├─────────────────────────────►│
│ │ │ │ vendor revoke
│ │ │ │ ack
│ │ │ persist: state=revoked │
│ │ │ outbox: lock.credential │
│ │ │ .revoked.v1 │
│ │ lock.credential │ │
│ │ .revoked.v1 │ │
│ │◄──────────────────────┤ │

8.2 Sequence — offline check-in / checkout (Electron)

When the desktop is offline at checkout:

desktop-renderer desktop-main local-adapter local-outbox ws-relay (later)
│ │ │ │ │
│ checkoutGuest() │ │ │ │
├─────────────────►│ │ │ │
│ │ revoke local │ │ │
│ ├───────────────►│ │ │
│ │ │ invalidate at │ │
│ │ │ encoder / BLE │ │
│ │ │ if reachable │ │
│ │ │ │ │
│ │ enqueue │ │ │
│ │ lock.credential.revoked.local.v1 │
│ ├──────────────────────────────────────►│ │
│ │ │ │
│ ack │ │ │
│◄─────────────────┤ │ │
│ │ │
│ ... time passes, network returns ... │ │
│ │ │
│ │ flush queue │
│ ├────────────────────►│
│ │ │ cloud reconciles:
│ │ │ marks revoked,
│ │ │ emits canonical
│ │ │ lock.credential
│ │ │ .revoked.v1

8.3 Invariants on revoke

  • Revoke is idempotent. A second revoke on an already-revoked credential is a no-op that returns success.
  • Revoke is best-effort propagated to the lock, but the platform considers the credential revoked the instant the revoked state is persisted. Any subsequent door access is logged as an audit anomaly.
  • A failed revoke at the vendor produces MELMASTOON.LOCK.KEY_REVOKE_FAILED and immediately raises a security alert (PagerDuty, runbook lock/key-revoke). The credential is still persisted as revoked in our store; the runbook escalates manual intervention.

9. Offline Issuance on Electron

9.1 When this path activates

The desktop's local LockPort adapter is invoked when all of the following are true:

  1. The desktop has no network reachability to lock-integration-service (fast-fail probe < 800 ms or last sync heartbeat > 30 s old).
  2. A USB/serial encoder is paired and online: true, or a TTLock device is in BLE range and the local TTLock adapter is configured for that property.
  3. The staff member has the lock.key.issue.offline permission (a tenant-configurable role attribute).

If any condition fails, the renderer surfaces the manual fallback (handwritten log + vendor portable tool — degrades experience but does not block guest entry).

9.2 Local issuance flow

  1. Renderer calls window.melmastoon.locks.issueOffline({ reservationId, kind, rooms, validFrom, validUntil }).
  2. Main-process IPC handler validates role, loads reservation snapshot from SQLite (must be confirmed and have paymentStatus in ('captured','cash_on_arrival_pending')).
  3. Main process invokes the local LockPort adapter (Wiegand encoder or BLE TTLock). Adapter encodes/provisions the credential.
  4. Main process persists KeyCredential row to local SQLite with:
    • id = ULID generated locally (prov_keycred_<ulid>)
    • provisional = true
    • vendorRef (opaque, encoder card serial or BLE eKey id)
    • state = 'active'
  5. Main process enqueues lock.credential.issued.local.v1 to the local outbox with full payload (no PII beyond what's in the reservation snapshot).
  6. Renderer receives the IssueResult and shows the staff "Card ready" / "PIN: XXXXXX" screen.
  7. On next /sync/v1/push, the local outbox is drained.
  8. lock-integration-service reconciler:
    • If reservation is still confirmed and matches the provisional, materializes a canonical KeyCredential row, swaps the id to a server-assigned ULID, and emits canonical lock.credential.issued.v1. The local row's id is mapped via localId → serverId in the desktop SQLite for future operations.
    • If reservation has since been cancelled, the reconciler revokes the provisional at the vendor (or marks for next online attempt) and emits lock.credential.revoked.v1 with reason cancellation.

9.3 Time bounds and signed offline certificate

  • Each tenant is provisioned with a signed offline issuance certificate (Ed25519, expiring every 90 days) by lock-integration-service at desktop pairing time. The certificate carries tenantId, propertyId, allowed kinds, max validUntil window, and a serial number. The local adapter refuses to issue without a valid, unrevoked certificate.
  • Provisional credentials carry a hard local cap on validUntil of 48 hours beyond validFrom to bound the blast radius of an unrecoverable offline period. If the stay is longer, the credential will be re-issued / extended on first reconnect.
  • Offline issuance attempts are themselves audited locally and replayed to audit-service on sync.

9.4 What the renderer never does

  • Never imports node-hid, serialport, @abandonware/noble, or any vendor SDK.
  • Never reads or writes the offline issuance certificate.
  • Never sees the encoder USB handle.
  • Communicates exclusively via the narrow window.melmastoon.locks.* IPC surface.

10. Mobile Key Delivery

10.1 Channels

ChannelWhen usedContentFallback
Push notification (tenant booking app)Guest installed the tenant booking app and is logged inDeep link melmastoon://tenant/<slug>/keys/<keyCredentialId>If undelivered after 60 s, fall back to email
EmailAlways sent as belt-and-bracesHTML + plaintext; deep link button + QR fallback + PIN code if applicableIf bounce, fall back to SMS
SMSSent if the tenant has SMS enabled and the guest has a phone numberShort URL → universal link to the booking app or web booking site → key surfaceIf undelivered, raise alert; staff prompt at desk
QR fallback (in confirmation email)Always included as a printable backupStatic QR encoding the deep linkn/a
PIN fallback (in confirmation email + SMS)When kind = 'pin_code', or guest has no smartphone6–8 digit PIN, formatted with groupingn/a

10.2 Delivery saga

The notification fan-out is owned by notification-service, triggered by lock.credential.issued.v1. Templates are picked by kind:

lock.credential.issued.v1


notification-service

├─► template.lock.mobile_key.invite.<locale> if kind = 'mobile_app'
│ channels: push (primary), email, SMS

├─► template.lock.pin_code.delivered.<locale> if kind = 'pin_code'
│ channels: email + SMS

├─► template.lock.rfid_card.ready_at_desk.<locale> if kind = 'rfid_card'
│ channels: email (informational; pickup happens at desk)

└─► template.lock.qr_code.delivered.<locale> if kind = 'qr_code'
channels: email + push

All templates exist in all platform locales (Pashto, Dari, Arabic, EN, FR at MVP) with proper RTL layout for RTL locales.

10.3 Privacy

  • The deep link itself does not carry the credential bytes. It carries keyCredentialId and a single-use exchange token; the booking app exchanges the token at bff-tenant-booking-service for the actual mobile-key artifact.
  • The exchange is gated by guest authentication (booking-session token issued at confirmation, with optional step-up for first-time use).
  • PINs in email/SMS are sent in plaintext (industry standard for hotel PINs); they are scoped to the room set and the stay window only.

11. Lost-Key Flow

A guest reports a lost card or compromised mobile key.

1. Staff opens reservation in desktop.
2. Staff clicks "Replace key" → reason = 'lost'.
3. Renderer → main: requestReplaceKey(reservationId, reason='lost').
4. Main → cloud: POST /lock/v1/credentials/:id/revoke (reason='lost').
5. lock-integration-service:
- LockPort.revokeCredential(id, 'lost')
- emit lock.credential.revoked.v1
- immediately invoke the issue saga to produce a new credential (same kind by default;
staff may switch kind, e.g., mobile_app → rfid_card)
6. New credential → notification fan-out as in §10.
7. audit-service records both the revoke and the re-issue with operatorId, reason, and
the link between old and new keyCredentialId.

If the desktop is offline:

  • Local adapter revokes at the encoder (best-effort) and queues lock.credential.revoked.local.v1.
  • Local adapter issues a new provisional credential.
  • Cloud reconciler links the two on next sync.

The audit trail is intentionally explicit so that compliance reviews can see exactly when access was withdrawn.


12. Master-Key Model

Staff do not get reservation-tied credentials. They get role-bound master keys with explicit scope and time bounds.

12.1 Scopes

ScopeMeaningTypical role
room:<roomId>A single room (rare; emergency repair)Maintenance for the duration of a work order
floor:<floor>All rooms on a floorHousekeeping floor lead
rooms:assignedComputed dynamically: rooms with the staff member's open housekeeping/maintenance tasksHousekeeper, maintenance technician
rooms:allAll guest rooms in the propertyGM, head housekeeper
areas:<area>Common areas: lobby, gym, pool, spa, parkingConcierge, security
areas:allAll common areasGM
back_of_houseService corridors, storage, officesAll staff (subject to door-level config)

12.2 Time bounds

  • Every staff master key is shift-bound. The validFrom and validUntil come from the staff member's current shift (from staff-service when implemented; from a manual entry in iam-service user attributes at MVP).
  • Outside of shift, the credential is suspended. On shift start, the desktop or staff mobile triggers unsuspendCredential. Some vendors allow per-day-of-week recurring schedules natively; we use them where available. Where unavailable, we suspend/unsuspend on the schedule.

12.3 Issuance

Master keys are not issued by the booking saga. They are issued by an explicit operator action:

backoffice operator (GM)


bff-backoffice-service


lock-integration-service


LockPort.issueCredential({
...,
rooms: [resolved from scope],
scope: { floors, areas },
guest: { id: staffUserId, displayName: staffName } // staff treated as the "holder"
})


emit lock.credential.issued.v1 (with metadata.kind = 'staff_master')

12.4 Audit

Every door access by a staff master key is logged at the lock and pulled into audit-service via vendor webhooks (where supported) or polled via lock-integration-service daily. Anomaly detection (off-shift use, repeated denied attempts) routes to the ai-orchestrator-service anomaly capability.


13. Security

Lock credentials are the most security-sensitive secrets the platform handles after PCI data. Their storage, rotation, and access path are isolated from everything else.

13.1 Storage

  • Vendor API keys live in Secret Manager under the lock/ namespace. IAM policy permits only the lock-integration-service runtime identity (Cloud Run service account) to read. No platform admin, no other service account, no human has standing read access. Break-glass access requires a documented incident, dual approval, and rotates the secret on close-out.
  • Per-property vendor configuration (encoder model, lock device list, BLE pairing keys, on-prem connector endpoints) lives in lock-integration-service's Postgres schema, encrypted at rest with a separate KMS key (projects/<project>/locations/<region>/keyRings/melmastoon-lock/cryptoKeys/lock-config) from the platform default key. CMEK supported on Plus tier.
  • vendorRef values are stored opaquely; no other service has DB access to this column.
  • Signed offline issuance certificates are persisted in the desktop's encrypted SQLite, with the private key referenced via the OS keychain (keytar) and never written to disk in plaintext.

13.2 Least privilege per device

  • Each lock device's vendor credentials are scoped per-property where the vendor supports it (TTLock per-app, Salto per-environment, Vostio per-environment).
  • Cross-property credential reuse is forbidden by configuration validation at tenant onboarding.
  • Devices that are decommissioned have their credentials rotated within 24 h and the old credentials revoked at the vendor.

13.3 Logging

  • Vendor credentials are never logged. The adapter boundary scrubs payloads before they reach the structured logger.
  • vendorRef is never logged (treated as a credential).
  • Lock action metadata (keyCredentialId, reservationId, operatorId, reason, result) is logged; it is the audit substrate.
  • Logs are shipped to Cloud Logging with severity, service=lock-integration-service, traceId, tenantId. Per tenantId, retention is governed by the tenant's audit policy.

13.4 Audit trail

Every lock action emits a lock.*.v1 event. audit-service consumes all lock.*.v1 events and persists them in the immutable append-only log. The daily Merkle anchor (see 02 §14) covers lock events. This makes the lock audit trail tamper-evident for the full retention window.

13.5 Secret rotation

SecretRotation cadenceOwner
TTLock OAuth client_secret90 dTenant onboarding job
Salto API key90 dTenant onboarding job
Vostio OAuth client_secret90 dTenant onboarding job
Wiegand encoder pairing keyAt pairing; on encoder replacementDesktop pairing wizard
Offline issuance certificate (Ed25519)90 d, with 14-day overlaplock-integration-service cert authority
KMS key for lock-config encryptionAnnual auto-rotation; CMEK schedule for tenants on PlusPlatform

14. Failure Modes

FailureDetectionGraceful degradationManual override pathAudit
Vendor cloud downAdapter timeout / HTTP 5xxSaga retries with backoff; after exhaustion → lock.vendor.error.v1; reservation flow continuesDesktop offers manual key issuance via local encoder if available; or vendor portable tool with handwritten logMELMASTOON.LOCK.VENDOR_UNREACHABLE logged with vendor, reservationId
Lock device offline (battery / network)healthCheck returns online: false; vendor returns issue errorSaga still records KeyCredential as active (the credential bytes are valid); when device reconnects, vendor propagates to lock; in the meantime, staff escorts guest if neededMechanical key bypass; documented per propertyMELMASTOON.LOCK.DEVICE_NOT_PAIRED if device unknown; warning in DeviceHealth otherwise
Encoder USB disconnectedMain-process usbDetect event; local adapter returns CARD_ENCODER_OFFLINERenderer prompts staff to re-seat; offers PIN issuance instead if vendor supportsRe-pair encoder via desktop wizardMELMASTOON.LOCK.CARD_ENCODER_OFFLINE
BLE pairing failure (TTLock)Adapter reports BLE timeoutFall back to cloud path; if offline, fall back to PIN; if no PIN support, manual keyMove closer to lock; reseat gatewayMELMASTOON.LOCK.KEY_ISSUE_FAILED with vendor='ttlock' and vendorMessage containing redacted BLE error
PIN collision (vendor refuses duplicate PIN within property)Adapter returns vendor-specific code, normalized to KEY_ISSUE_FAILEDSaga regenerates PIN with cryptographic RNG and retries (max 3); after exhaustion, fall back to a different kind per preferredKindsStaff overrides PIN selectionMELMASTOON.LOCK.KEY_ISSUE_FAILED
Lock device clock skewhealthCheck.clockSkewMs > 300_000Warning surfaced in desktop ops dashboard; saga continues but flags issued credentials with warnings: [{ code: 'CLOCK_SKEW', detail }]Schedule device sync via vendor app; replace batteryHealth snapshot persisted; alert at > 600_000
Vendor webhook signature invalidWebhook receiverReject 401; alert; do not enqueue any state changeInvestigate as potential spoof or rotated secretMELMASTOON.PAYMENT.WEBHOOK_SIGNATURE_INVALID analogue: MELMASTOON.LOCK.WEBHOOK_SIGNATURE_INVALID (added to error codes if not present)
Provisional offline credential on a now-cancelled reservationReconciler diffRevoke at vendor on next sync (best-effort); persist revoked state immediatelyn/alock.credential.revoked.v1 with reason 'cancellation' and metadata { wasProvisional: true }
Vendor changes API behavior unannouncedContract test failure in CI; staging canaryBlock deploy; runbookAdapter patch + new releaseCaptured in incident report

In every failure mode, the platform's invariant is: the guest can get into the room. Either via mobile key, PIN, RFID card, or — in the absolute worst case — a staff escort with a mechanical bypass key documented in the per-property runbook.


15. Testing

15.1 Levels

LevelWhat it testsWhere it runs
UnitDomain (KeyCredential aggregate, state machine, invariants)Pure TS, no I/O, every PR
Adapter contract testsEach adapter's contract against an in-memory mock of the vendor's documented behaviorPer PR
Integration with mock adapterSaga + ports + repos against an in-memory MockLockPort registered for vendor='mock'Per PR; CI default
Vendor sandboxReal adapter against the vendor's sandbox environment (TTLock dev, Salto Sandbox, Vostio Sandbox)Nightly; on adapter PRs; gated for cost
Hardware-in-the-loopReal Wiegand encoder + sample MIFARE cards on a physical lab rigOn Wiegand adapter PRs; weekly regression
Production canaryOne pilot property per vendor receives new adapter builds 7 days ahead of fleetContinuous

15.2 Mock adapter

MockLockPort is the default adapter in CI:

  • Implements LockPort end-to-end, in-memory.
  • Configurable failure injection (e.g., mock://?fail=issue&pct=20&error=VENDOR_UNREACHABLE).
  • Configurable latency profile.
  • Configurable describeAdapter().capabilities to test capability-conditional saga branches.

15.3 Vendor simulators

Where vendors do not provide a usable sandbox, we maintain vendor simulators under services/lock-integration-service/test/simulators/<vendor>/. Each simulator:

  • Speaks the wire protocol of the vendor (REST, BLE-mocked-over-IPC, serial-mocked-over-pty).
  • Documents simulated behavior in a README.
  • Is updated to track real vendor behavior on a quarterly cadence.

15.4 Chaos testing

Per chaos calendar:

  • Pull the network on the desktop mid-issue → verify offline path activates and reconciles cleanly.
  • Force vendor 5xx for 5 minutes → verify retry/backoff and runbook-compatible alerting.
  • Replay duplicate reservation.confirmed.v1 100 times → verify exactly-one credential issued.
  • Simulate clock skew of 30 minutes on a TTLock device → verify warning, no incorrect issuance.

15.5 Production canary

New adapter builds roll to one property per vendor for 7 days before fleet-wide release. Canary metrics watched: issue.success.rate, issue.p95.ms, revoke.success.rate, vendor.error.rate. Auto-rollback on > 1% degradation vs trailing 7-day baseline.


16. Onboarding a New Vendor

Adding a vendor is a contained, repeatable workflow.

16.1 Checklist

  • SDK evaluation — license terms, runtime dependency footprint, language bindings, sandbox availability, rate limits, region availability.
  • Security review — authentication model, secret rotation surface, audit log surface, PII handling at the vendor, data residency.
  • Capability mapping — fill out the LockCapabilities matrix; document any capability the vendor exposes that the port does not (and why we do not expose it).
  • Implement adapter — under infrastructure/adapters/<vendor>/. Domain may not change; if it must, an ADR is required.
  • Implement webhook receiver if the vendor pushes state changes — under presentation/controllers/webhooks/<vendor>.controller.ts. Signature verification is mandatory.
  • Register adapter with the saga's adapter registry, keyed by vendor code per property config.
  • Sandbox tests — full happy path + failure injection.
  • Contract tests — every LockPort method, including idempotency, cancellation mid-flight, and capability-conditional branches.
  • E2E on staging — issue / update / suspend / unsuspend / revoke against the vendor sandbox via the full saga.
  • Hardware-in-the-loop test if the adapter is on-prem (Wiegand, Salto on-prem connector).
  • Runbookrunbooks/lock/<vendor>/ with: onboarding a property, rotating credentials, common errors, escalation contacts at the vendor, known issues.
  • Cost model — per-issue, per-revoke, per-month vendor pricing; integrated into analytics-service for tenant cost reporting.
  • Production canary — one pilot property for 7 days before fleet rollout.
  • Documentation — update §2 vendor matrix, §5 adapter notes, ERROR_CODES.md if any new vendor-specific normalized codes are added, and the per-service SERVICE_README.md for lock-integration-service.

16.2 Definition of done

A vendor is "shipped" when:

  1. All contract tests pass against the vendor sandbox.
  2. The hardware-in-the-loop rig passes a 24-hour soak with > 99.5% success.
  3. The runbook is reviewed by the on-call rotation.
  4. The pilot property has run for 7 days with no S1/S2 incidents and issue.success.rate >= 99.5%.

17. Compliance

17.1 Audit retention

Hospitality regulators in several target markets (notably the GCC and parts of the EU under EN 14846 and local hospitality codes) require a tamper-evident audit trail of physical access events for a minimum of 5 years. Melmastoon retains lock-related audit records for 7 years by default, configurable longer per tenant.

The audit substrate is audit-service's append-only log. Lock events are anchored daily into the Merkle root, providing tamper evidence for the full retention window.

17.2 What is retained

For each lock action:

  • keyCredentialId, reservationId (if guest), staffUserId (if staff master), operatorId (who initiated, if not the saga), propertyId, tenantId.
  • Action type (issued, updated, suspended, unsuspended, revoked).
  • Reason code.
  • vendor, normalized capability snapshot at the time of the action.
  • Timestamp (server-issued, monotonic), traceId.
  • For door-level access events ingested via vendor webhooks: deviceId, granted | denied, reason (e.g., expired, valid, suspended).

17.3 What is not retained

  • Vendor credential bytes, vendor vendorRef opaque identifiers (kept in operational storage, not audit), PII beyond the minimum required to identify the action subject.
  • Free-text from staff (subject to data-minimization policy).

17.4 Data subject requests

When a guest exercises a DSAR (export or erasure), the lock audit records associated with their guestId are included in the export. Erasure of lock audit records is not granted — they are retained for the legal retention window under the regulatory carve-out for safety and compliance records, with the guest's identifying fields pseudonymized after the retention window expires.

17.5 Data residency

Lock records inherit the tenant's data_residency pin (see 02 §14). For tenants pinned to me-central1, lock records never leave the region. Vendor calls that egress (e.g., TTLock cloud is global) are documented in the tenant's data-processing record and the tenant onboarding consent.


Cross-references: per-service deep doc lives at services/lock-integration-service/. Saga shape: 04 Event-Driven Architecture. Booking-driven flow: 02 §7.3. Desktop offline path: 12 Desktop Spec §Offline. Error codes: LOCK section. Architectural rationale: ADR-0004. Renderer/main isolation rationale: ADR-0003.