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
| Lever | Status quo | With Melmastoon |
|---|---|---|
| Avg front-desk time per check-in | 4–8 min | 30–90 sec (or zero with mobile key) |
| Lost-card cost per stay (5%–10% of stays) | USD 0.40–2.50 | USD 0.05–0.30 (re-issue is software) |
| Time-to-revoke a missing card | 30+ min (re-key) | < 5 sec (saga revoke) |
| Audit trail completeness | Paper / inconsistent | Immutable per-action log, 7 yr retention |
| Late-night staffing requirement | Mandatory | Optional (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/validUntilenforced 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-servicedaily 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
| Vendor | Connectivity | Key types | Cloud / On-prem | Target market presence | SDK language | Certification posture | Cost class | Phase |
|---|---|---|---|---|---|---|---|---|
| TTLock | BLE + cloud API; gateway for remote ops | mobile_app, pin_code, rfid_card | Cloud-first; optional gateway | Strong in Asia, Pakistan, Iran, Afghanistan, India; growing in MENA | Node, Java, .NET, mobile SDKs | None formal; ISO 27001 vendor claim | Low (USD 30–80/lock) | Phase 1 |
| Salto SVN (XS4 line) | XS4 cloud API + on-prem SVN connector for legacy installs | rfid_card (primary), mobile_app, pin_code (subset) | Hybrid — cloud + on-prem connector | Strong in mid-market chains globally; established in GCC, EU | REST + on-prem connector binary | UL, EN 14846, ISO/IEC 27001 | Mid (USD 200–500/lock) | Phase 1 |
| Assa Abloy Vostio | Cloud-first REST; Aperio for wireless online; Visionline for legacy bridge | mobile_app (primary), rfid_card, nfc_tag | Cloud (Vostio); on-prem (Visionline bridge) | Enterprise hospitality globally; chains | REST + Vostio SDK | UL, EN, ISO/IEC 27001, SOC 2 | High (USD 350–800/lock) | Phase 1 |
| Generic Wiegand / RFID | Encoder over USB or RS-232/485 serial; offline-only by design | rfid_card, nfc_tag (encoder-dependent) | On-prem only — encoder lives at front desk | Long-tail of legacy installs everywhere | Vendor-specific HID/serial protocols, wrapped by node-hid / serialport | None | Very low (existing hardware) | Phase 1 |
| Dormakaba | Cloud (Ambiance) + on-prem (Saflok) | rfid_card, mobile_app | Hybrid | Strong enterprise EU + US | REST + .NET SDK (cloud); proprietary (Saflok) | UL, EN, ISO/IEC 27001 | High | Phase 3 |
| Kaba (legacy) | Serial / proprietary protocol via on-site encoder | rfid_card | On-prem only | Long-tail legacy globally | Proprietary | None | Very low (legacy) | Phase 4 |
2.2 Phase rollout commitment
| Phase | Vendors shipped | Rationale |
|---|---|---|
| MVP (Phase 1) | TTLock, Salto SVN, Assa Abloy Vostio, Generic Wiegand | Covers >90% of target-market hardware: TTLock for emerging markets, Salto for mid-market chains, Vostio for enterprise, Wiegand for legacy. |
| Phase 2 | Hardening — extended capability flags, BLE gateway support for TTLock, Salto offline-validity propagation refinement | No new vendors; deepen the four already shipped. |
| Phase 3 | Dormakaba (Ambiance + Saflok) | Required for enterprise EU/US chain expansion. |
| Phase 4 | Kaba legacy bridge | Long-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
| Adapter | Cloud (lock-integration-service) | Electron main |
|---|---|---|
| TTLock — cloud-only ops | Yes (default for remote issue/revoke when cloud is healthy) | Yes (BLE direct when cloud unreachable and lock in BLE range) |
| Salto SVN — cloud REST | Yes (default) | No |
| Salto SVN — on-prem connector | No (connector is a Salto-deployed binary at the property; cloud adapter calls it via the property's tunnel) | No |
| Assa Abloy Vostio | Yes (cloud-only) | No |
| Generic Wiegand / RFID encoder | Cloud 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.*contextBridgesurface (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 localLockPortadapter (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/nobleimport 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
idempotencyKeydirectly or works on an idempotent identifier (keyCredentialId). - No method returns vendor SDK types. The adapter translates all vendor errors to
LockError. IssuedCredential.vendorRefis 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: trueis 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)
| Property | Value |
|---|---|
| Connectivity | TTLock cloud REST (default); BLE direct via gateway or via Electron main |
| Auth flow | OAuth2 client_credentials per tenant; per-property gateway token issued by tenant onboarding |
| Key kinds | mobile_app (signed eKey), pin_code, rfid_card (subset of locks) |
| Latency target | Cloud issue p95 < 1.2 s; BLE direct p95 < 3 s |
| Offline issuance | BLE direct from Electron main when in range; provisional |
| Vendor-specific failure modes | Gateway 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)
| Property | Value |
|---|---|
| Connectivity | XS4 cloud REST + on-prem connector binary (Salto-deployed at the property) |
| Auth flow | API key per tenant (rotated 90 d); on-prem connector authenticated via mTLS to the cloud REST |
| Key kinds | rfid_card (primary), mobile_app (Salto KS subset), pin_code (subset) |
| Latency target | Cloud issue p95 < 2 s; on-prem connector adds ≤ 500 ms |
| Offline issuance | Limited — Salto's offline-validity propagation graph allows time-bound cards to keep working even if the lock is offline |
| Vendor-specific failure modes | On-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
| Property | Value |
|---|---|
| Connectivity | Cloud REST (Vostio Access Management API) |
| Auth flow | OAuth2 client_credentials per tenant; tenant-specific Vostio environment |
| Key kinds | mobile_app (Vostio Mobile Access), rfid_card, nfc_tag |
| Latency target | Cloud issue p95 < 1.5 s |
| Offline issuance | Not supported by vendor; offline path uses local Wiegand encoder if available |
| Vendor-specific failure modes | Vostio 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
| Property | Value |
|---|---|
| Connectivity | USB HID or RS-232/485 serial via Electron main; cloud talks to desktop via authenticated WebSocket relay |
| Auth flow | Encoder-local (no vendor cloud); device pairing via desktop onboarding wizard |
| Key kinds | rfid_card, nfc_tag (encoder-dependent) |
| Latency target | Encode p95 < 2.5 s on USB |
| Offline issuance | First-class — this adapter is fundamentally offline |
| Vendor-specific failure modes | Encoder 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
activewithout traversingpending. The saga waits for explicit vendor confirmation. - Revoke is terminal. A
revokedcredential is never reactivated; re-issue creates a newKeyCredentialId. - Suspend is reversible. Only
suspended → activeis allowed viaunsuspend. - Active window is enforced at the lock. Even if the platform fails to revoke,
validUntilcauses the lock to refuse the credential. - Provisional credentials reconcile or revoke. A credential issued offline with
provisional: trueis 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 event | KeyCredential action |
|---|---|
reservation.confirmed.v1 | Create requested; saga advances through pending → active |
reservation.dates_changed.v1 | updateCredential — extend or contract validUntil; if room changed, also rotate rooms |
reservation.no_show.v1 | suspendCredential(reason: 'no_show') |
reservation.early_checkout.v1 | revokeCredential(reason: 'checkout') |
reservation.checkout.v1 | revokeCredential(reason: 'checkout') |
reservation.cancelled.v1 | revokeCredential(reason: 'cancellation') |
reservation.fraud_flagged.v1 | suspendCredential(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
| Failure | Detection | Compensation |
|---|---|---|
Vendor returns LockError.VENDOR_UNREACHABLE | Adapter | Saga 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 permanently | Adapter | Same as above; no retry. |
lock.credential.issued.v1 published but notification fails | Notification subscriber DLQ | Notification replay; no compensation in lock subsystem (key already issued). |
reservation.cancelled.v1 arrives during issue saga | Saga state | If 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 step | Second delivery is a no-op; returns the existing KeyCredentialId. |
| Adapter crash mid-call (cloud Run instance dies) | Saga state outlives instance | On 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-
revokedcredential 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
revokedstate is persisted. Any subsequent door access is logged as an audit anomaly. - A failed revoke at the vendor produces
MELMASTOON.LOCK.KEY_REVOKE_FAILEDand immediately raises a security alert (PagerDuty, runbooklock/key-revoke). The credential is still persisted asrevokedin 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:
- The desktop has no network reachability to
lock-integration-service(fast-fail probe < 800 ms or last sync heartbeat > 30 s old). - 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. - The staff member has the
lock.key.issue.offlinepermission (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
- Renderer calls
window.melmastoon.locks.issueOffline({ reservationId, kind, rooms, validFrom, validUntil }). - Main-process IPC handler validates role, loads reservation snapshot from SQLite (must be
confirmedand havepaymentStatus in ('captured','cash_on_arrival_pending')). - Main process invokes the local
LockPortadapter (Wiegand encoder or BLE TTLock). Adapter encodes/provisions the credential. - Main process persists
KeyCredentialrow to local SQLite with:id= ULID generated locally (prov_keycred_<ulid>)provisional=truevendorRef(opaque, encoder card serial or BLE eKey id)state='active'
- Main process enqueues
lock.credential.issued.local.v1to the local outbox with full payload (no PII beyond what's in the reservation snapshot). - Renderer receives the
IssueResultand shows the staff "Card ready" / "PIN: XXXXXX" screen. - On next
/sync/v1/push, the local outbox is drained. lock-integration-servicereconciler:- If reservation is still
confirmedand matches the provisional, materializes a canonicalKeyCredentialrow, swaps theidto a server-assigned ULID, and emits canonicallock.credential.issued.v1. The local row'sidis mapped vialocalId → serverIdin 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 emitslock.credential.revoked.v1with reasoncancellation.
- If reservation is still
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-serviceat desktop pairing time. The certificate carriestenantId,propertyId, allowedkinds, maxvalidUntilwindow, and a serial number. The local adapter refuses to issue without a valid, unrevoked certificate. - Provisional credentials carry a hard local cap on
validUntilof 48 hours beyondvalidFromto 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-serviceon 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
| Channel | When used | Content | Fallback |
|---|---|---|---|
| Push notification (tenant booking app) | Guest installed the tenant booking app and is logged in | Deep link melmastoon://tenant/<slug>/keys/<keyCredentialId> | If undelivered after 60 s, fall back to email |
| Always sent as belt-and-braces | HTML + plaintext; deep link button + QR fallback + PIN code if applicable | If bounce, fall back to SMS | |
| SMS | Sent if the tenant has SMS enabled and the guest has a phone number | Short URL → universal link to the booking app or web booking site → key surface | If undelivered, raise alert; staff prompt at desk |
| QR fallback (in confirmation email) | Always included as a printable backup | Static QR encoding the deep link | n/a |
| PIN fallback (in confirmation email + SMS) | When kind = 'pin_code', or guest has no smartphone | 6–8 digit PIN, formatted with grouping | n/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
keyCredentialIdand a single-use exchange token; the booking app exchanges the token atbff-tenant-booking-servicefor 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
| Scope | Meaning | Typical role |
|---|---|---|
room:<roomId> | A single room (rare; emergency repair) | Maintenance for the duration of a work order |
floor:<floor> | All rooms on a floor | Housekeeping floor lead |
rooms:assigned | Computed dynamically: rooms with the staff member's open housekeeping/maintenance tasks | Housekeeper, maintenance technician |
rooms:all | All guest rooms in the property | GM, head housekeeper |
areas:<area> | Common areas: lobby, gym, pool, spa, parking | Concierge, security |
areas:all | All common areas | GM |
back_of_house | Service corridors, storage, offices | All staff (subject to door-level config) |
12.2 Time bounds
- Every staff master key is shift-bound. The
validFromandvalidUntilcome from the staff member's current shift (fromstaff-servicewhen implemented; from a manual entry iniam-serviceuser attributes at MVP). - Outside of shift, the credential is
suspended. On shift start, the desktop or staff mobile triggersunsuspendCredential. 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 thelock-integration-serviceruntime 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. vendorRefvalues 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.
vendorRefis 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. PertenantId, 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
| Secret | Rotation cadence | Owner |
|---|---|---|
| TTLock OAuth client_secret | 90 d | Tenant onboarding job |
| Salto API key | 90 d | Tenant onboarding job |
| Vostio OAuth client_secret | 90 d | Tenant onboarding job |
| Wiegand encoder pairing key | At pairing; on encoder replacement | Desktop pairing wizard |
| Offline issuance certificate (Ed25519) | 90 d, with 14-day overlap | lock-integration-service cert authority |
| KMS key for lock-config encryption | Annual auto-rotation; CMEK schedule for tenants on Plus | Platform |
14. Failure Modes
| Failure | Detection | Graceful degradation | Manual override path | Audit |
|---|---|---|---|---|
| Vendor cloud down | Adapter timeout / HTTP 5xx | Saga retries with backoff; after exhaustion → lock.vendor.error.v1; reservation flow continues | Desktop offers manual key issuance via local encoder if available; or vendor portable tool with handwritten log | MELMASTOON.LOCK.VENDOR_UNREACHABLE logged with vendor, reservationId |
| Lock device offline (battery / network) | healthCheck returns online: false; vendor returns issue error | Saga still records KeyCredential as active (the credential bytes are valid); when device reconnects, vendor propagates to lock; in the meantime, staff escorts guest if needed | Mechanical key bypass; documented per property | MELMASTOON.LOCK.DEVICE_NOT_PAIRED if device unknown; warning in DeviceHealth otherwise |
| Encoder USB disconnected | Main-process usbDetect event; local adapter returns CARD_ENCODER_OFFLINE | Renderer prompts staff to re-seat; offers PIN issuance instead if vendor supports | Re-pair encoder via desktop wizard | MELMASTOON.LOCK.CARD_ENCODER_OFFLINE |
| BLE pairing failure (TTLock) | Adapter reports BLE timeout | Fall back to cloud path; if offline, fall back to PIN; if no PIN support, manual key | Move closer to lock; reseat gateway | MELMASTOON.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_FAILED | Saga regenerates PIN with cryptographic RNG and retries (max 3); after exhaustion, fall back to a different kind per preferredKinds | Staff overrides PIN selection | MELMASTOON.LOCK.KEY_ISSUE_FAILED |
| Lock device clock skew | healthCheck.clockSkewMs > 300_000 | Warning surfaced in desktop ops dashboard; saga continues but flags issued credentials with warnings: [{ code: 'CLOCK_SKEW', detail }] | Schedule device sync via vendor app; replace battery | Health snapshot persisted; alert at > 600_000 |
| Vendor webhook signature invalid | Webhook receiver | Reject 401; alert; do not enqueue any state change | Investigate as potential spoof or rotated secret | MELMASTOON.PAYMENT.WEBHOOK_SIGNATURE_INVALID analogue: MELMASTOON.LOCK.WEBHOOK_SIGNATURE_INVALID (added to error codes if not present) |
| Provisional offline credential on a now-cancelled reservation | Reconciler diff | Revoke at vendor on next sync (best-effort); persist revoked state immediately | n/a | lock.credential.revoked.v1 with reason 'cancellation' and metadata { wasProvisional: true } |
| Vendor changes API behavior unannounced | Contract test failure in CI; staging canary | Block deploy; runbook | Adapter patch + new release | Captured 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
| Level | What it tests | Where it runs |
|---|---|---|
| Unit | Domain (KeyCredential aggregate, state machine, invariants) | Pure TS, no I/O, every PR |
| Adapter contract tests | Each adapter's contract against an in-memory mock of the vendor's documented behavior | Per PR |
| Integration with mock adapter | Saga + ports + repos against an in-memory MockLockPort registered for vendor='mock' | Per PR; CI default |
| Vendor sandbox | Real adapter against the vendor's sandbox environment (TTLock dev, Salto Sandbox, Vostio Sandbox) | Nightly; on adapter PRs; gated for cost |
| Hardware-in-the-loop | Real Wiegand encoder + sample MIFARE cards on a physical lab rig | On Wiegand adapter PRs; weekly regression |
| Production canary | One pilot property per vendor receives new adapter builds 7 days ahead of fleet | Continuous |
15.2 Mock adapter
MockLockPort is the default adapter in CI:
- Implements
LockPortend-to-end, in-memory. - Configurable failure injection (e.g.,
mock://?fail=issue&pct=20&error=VENDOR_UNREACHABLE). - Configurable latency profile.
- Configurable
describeAdapter().capabilitiesto 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.v1100 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
LockCapabilitiesmatrix; 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
vendorcode per property config. - Sandbox tests — full happy path + failure injection.
- Contract tests — every
LockPortmethod, 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).
- Runbook —
runbooks/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-servicefor 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.mdforlock-integration-service.
16.2 Definition of done
A vendor is "shipped" when:
- All contract tests pass against the vendor sandbox.
- The hardware-in-the-loop rig passes a 24-hour soak with > 99.5% success.
- The runbook is reviewed by the on-call rotation.
- 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
vendorRefopaque 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.