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
LockPortinterface (declared inapplication/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 consumed | Action |
|---|---|
reservation.confirmed.v1 | issueKey({ rooms, validFrom: arrival, validUntil: departure, ... }) |
reservation.cancelled.v1 | revokeKey({ reason: 'cancellation' }) |
reservation.checkout.v1 | revokeKey({ reason: 'checkout' }) |
reservation.dates_changed.v1 | updateKey({ validUntil: newDeparture }) (and rooms if room changed) |
reservation.no_show.v1 | suspendKey({ reason: 'no_show' }) (with manager unblock path) |
reservation.early_checkout.v1 | revokeKey({ 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 byrevokeKey,updateKey← compensated by reverting to priorvalidUntil, 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: trueand a short expiry. - A
lock.key.issued.local.v1event 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-serviceruntime 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 toaudit-serviceand are part of the daily Merkle anchoring.
Alternatives Considered
| Alternative | Why 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-service | Couples 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 desktop | Defeats 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 infrastructure | Same 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" SaaS | Adds 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-serviceconfiguration, 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
LockPortis 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'sdescribeAdapter().capabilitiesflags 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 thelock-integration-serviceruntime identity. IaC enforces. - Vendor secrets and vendor payloads must be scrubbed from logs at the adapter boundary. Log-redaction config enforces.
- Every
LockPortmethod must acceptidempotencyKey. 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-serviceconfiguration enforces. - The Electron desktop's local-vendor path must mark issued keys
provisional: trueuntil cloud reconciliation. Integration test enforces.