Skip to main content

ADR 0004: Lock & key integration abstraction (Ghasi Melmastoon)

Status

Accepted — 2026-04-22.

Context

Lock and key hardware in the small-and-medium hotel market is fragmented. A single platform cannot afford to ship a different code path per vendor or — worse — a different microservice per vendor. We have to support, at minimum:

  • TTLock — common in Asia and emerging markets; Bluetooth Low Energy + cloud API for remote ops; Node SDK is the canonical bindings.
  • Salto (XS4 family, SVN architecture) — common in mid-tier global hotels; XS4 cloud API + on-prem connector for legacy installations.
  • Assa Abloy Vostio — common in higher-end hotels; cloud API; mobile-key support.
  • Generic Wiegand / RFID encoders — long-tail legacy hardware in many properties; encoder over USB or serial via the Electron desktop main process.

The platform must also let a tenant change vendor (e.g. retrofit from a generic Wiegand encoder to TTLock) without changing the reservation flow, the saga code, the Electron desktop UX, or the audit log shape. Lock failures must degrade gracefully — a guest must be able to check in and get to their room even if the cloud lock vendor is having an outage.

Lock credentials are also the most security-sensitive secrets the platform handles after payment data: a leaked lock credential is a physical-access incident, not a digital one. Their storage, rotation, and access path must be isolated from everything else in the estate.

Decision

1. One service, one port, many adapters

We ship one service — lock-integration-service — that owns:

  • The LockPort interface (declared in application/ports/lock.port.ts).
  • All vendor adapters (TTLock, Salto SVN, Assa Abloy Vostio, Generic Wiegand/RFID) as infrastructure-layer adapters.
  • The key-lifecycle saga that listens to reservation events and orchestrates issue / update / revoke / suspend.
  • The isolated KMS-encrypted credential store for vendor API keys and per-property vendor configuration.
  • The vendor-event ACL that normalizes vendor webhooks / callbacks into platform events.
reservation.* events


┌──────────────────────────────┐
│ lock-integration-service │
│ │
│ key-lifecycle saga │
│ │ │
│ ▼ │
│ LockPort (interface) │
└──────┬───────────────────────┘

┌────────────────┼────────────────┬────────────────────┐
▼ ▼ ▼ ▼
┌──────────┐ ┌────────────┐ ┌─────────────┐ ┌──────────────────┐
│ TTLock │ │ Salto SVN │ │ Assa Abloy │ │ Generic Wiegand/ │
│ adapter │ │ adapter │ │ Vostio │ │ RFID adapter │
│ (BLE + │ │ (XS4 cloud │ │ adapter │ │ (encoder over │
│ cloud) │ │ + on-prem │ │ (cloud) │ │ USB/serial via │
│ │ │ connector)│ │ │ │ Electron main) │
└──────────┘ └────────────┘ └─────────────┘ └──────────────────┘

2. The LockPort interface

The port is vendor-agnostic. It models the business key lifecycle, not the vendor protocol.

export interface LockPort {
issueKey(input: {
tenantId: TenantId;
propertyId: PropertyId;
reservationId: ReservationId;
rooms: RoomId[];
validFrom: ISODate;
validUntil: ISODate;
guest: { id: GuestId; name: string; channel?: 'mobile' | 'card' | 'pin' };
idempotencyKey: string;
}): Promise<IssueKeyResult>;

updateKey(input: {
tenantId: TenantId;
keyCredentialId: KeyCredentialId;
rooms?: RoomId[];
validUntil?: ISODate;
idempotencyKey: string;
}): Promise<UpdateKeyResult>;

revokeKey(input: {
tenantId: TenantId;
keyCredentialId: KeyCredentialId;
reason: 'checkout' | 'cancellation' | 'security' | 'lost' | 'replaced';
idempotencyKey: string;
}): Promise<RevokeKeyResult>;

suspendKey(input: {
tenantId: TenantId;
keyCredentialId: KeyCredentialId;
reason: 'no_show' | 'fraud_review' | 'manual';
idempotencyKey: string;
}): Promise<SuspendKeyResult>;

unblockKey(input: {
tenantId: TenantId;
keyCredentialId: KeyCredentialId;
idempotencyKey: string;
}): Promise<UnblockKeyResult>;

describeAdapter(): {
vendor: 'ttlock' | 'salto' | 'assa-abloy' | 'generic-wiegand';
capabilities: {
mobileKey: boolean;
cardEncoding: boolean;
pin: boolean;
remoteOps: boolean;
offlineIssuance: boolean;
};
};
}

The result types (IssueKeyResult, etc.) carry the platform's normalized KeyCredential shape — never a raw vendor token, never a raw vendor card payload. The vendor's native artifact lives behind the adapter as an opaque blob keyed by keyCredentialId.

3. Saga: reservation events drive the key lifecycle

The key-lifecycle saga inside lock-integration-service listens to reservation events and calls LockPort accordingly:

Event consumedAction
reservation.confirmed.v1issueKey({ rooms, validFrom: arrival, validUntil: departure, ... })
reservation.cancelled.v1revokeKey({ reason: 'cancellation' })
reservation.checkout.v1revokeKey({ reason: 'checkout' })
reservation.dates_changed.v1updateKey({ validUntil: newDeparture }) (and rooms if room changed)
reservation.no_show.v1suspendKey({ reason: 'no_show' }) (with manager unblock path)
reservation.early_checkout.v1revokeKey({ reason: 'checkout' }) immediately

Every saga step:

  • Is idempotent by idempotencyKey = sha256(reservationId + step + version).
  • Is retried with exponential backoff + jitter on transient vendor errors.
  • Has a declared compensation: issueKey ← compensated by revokeKey, updateKey ← compensated by reverting to prior validUntil, etc.
  • Emits a platform event on completion: lock.key.issued.v1, lock.key.updated.v1, lock.key.revoked.v1, lock.key.suspended.v1, lock.vendor.error.v1.

4. Offline behavior (Electron desktop)

When the cloud lock-integration-service is unreachable but the desktop has a configured local adapter (e.g. a USB Wiegand encoder, or TTLock BLE within range), the desktop's main process can issue a provisional key locally:

  • The provisional key carries provisional: true and a short expiry.
  • A lock.key.issued.local.v1 event is queued in the local outbox.
  • On reconnect, the cloud saga reconciles: the provisional key is materialized as the authoritative KeyCredential, or revoked + re-issued if the saga's view of the reservation differs.

When neither cloud nor local adapter is available, the desktop surfaces a manual key fallback path (encode via vendor's portable tool, log the credential ID by hand). This degrades the experience but does not block guest entry.

5. Credential isolation

  • Vendor API keys live in Secret Manager under a separate namespace; access is restricted to the lock-integration-service runtime identity. No other Cloud Run service can read these secrets.
  • Per-property vendor configuration lives in lock-integration-service's own Postgres schema, encrypted at rest with a separate KMS key from the rest of the platform.
  • Vendor credentials are never logged. Logs scrub vendor payloads at the adapter boundary.
  • Vendor webhooks are received on a dedicated subdomain behind Kong with mTLS where vendor supports it, signature verification otherwise.
  • Lock-credential audit events (lock.key.issued.v1, etc.) are streamed to audit-service and are part of the daily Merkle anchoring.

Alternatives Considered

AlternativeWhy rejected
Per-vendor microservice (one service per vendor)Duplicates the saga in every service; the saga is the hard part, not the SDK call. We get N services to operate, N CI pipelines, N on-call playbooks, all to wrap one SDK each. The right shape is one saga, many adapters.
Direct vendor calls from reservation-serviceCouples the reservation aggregate to vendor SDKs; defeats Clean Architecture; spreads vendor secrets across the estate; spreads vendor-specific retry logic across services.
Client-only direct vendor calls from the Electron desktopDefeats audit (cloud has no record); spreads vendor secrets to N installed desktops; impossible to rotate; impossible to revoke a leaking client. We use the local adapter path only as offline fallback, never as the primary path.
Centralized vendor SDK monolith inside reservation-service infrastructureSame coupling as direct calls, just hidden one layer deeper. Vendor changes force reservation-service deploys, which is wrong.
One adapter per vendor, but no port (typed adapters with vendor-specific methods)Forces every consumer to know vendor specifics; rotating a tenant from Wiegand to TTLock becomes a code change everywhere. The port is the whole point.
Use a third-party "lock abstraction" SaaSAdds a third party to the most security-sensitive hardware integration; creates vendor lock-in we are explicitly trying to avoid; latency added between us and the lock; cost.

Consequences

Positive

  • One saga, one event surface, one port to maintain — vendor work is isolated to one adapter at a time.
  • Tenants can switch vendors without platform code changes; per-property vendor selection lives in lock-integration-service configuration, not in domain code.
  • Lock credentials are isolated by KMS key, schema, and runtime identity — a breach in any other service does not expose lock secrets.
  • The Electron desktop can operate on offline / hybrid local-vendor paths without inventing parallel platform contracts.
  • Adding a new vendor is a contained piece of work: implement LockPort, ship adapter, register with the saga's adapter registry, done.

Negative

  • The LockPort is the lowest common denominator of vendor capabilities; some vendor-specific features (e.g. Salto's offline-validity propagation graph) are exposed only via the adapter's describeAdapter().capabilities flags and consumed by the saga conditionally. Mitigated by keeping capability flags explicit and the saga capability-aware.
  • Vendor SDK quality varies wildly; some vendor SDKs assume a long-running process, some assume request-scoped. Adapters absorb this, at the cost of adapter complexity.
  • On-prem Salto connector requires a per-property network path; documented in deployment topology.

Compliance

  • No service may import a lock vendor SDK except lock-integration-service. CI dependency check enforces.
  • Vendor secrets must live in Secret Manager under the lock/* namespace; access policy permits only the lock-integration-service runtime identity. IaC enforces.
  • Vendor secrets and vendor payloads must be scrubbed from logs at the adapter boundary. Log-redaction config enforces.
  • Every LockPort method must accept idempotencyKey. Type-level enforcement.
  • Every saga step must declare a compensation. Saga registry tests enforce.
  • Every vendor adapter must implement describeAdapter() accurately. Adapter contract tests enforce.
  • Lock-related audit events (lock.*.v1) must be part of the daily Merkle anchor. audit-service configuration enforces.
  • The Electron desktop's local-vendor path must mark issued keys provisional: true until cloud reconciliation. Integration test enforces.

References