Skip to main content

SERVICE_OVERVIEW — lock-integration-service

Bundle index: SERVICE_OVERVIEW · DOMAIN_MODEL · APPLICATION_LOGIC · API_CONTRACTS · EVENT_SCHEMAS · DATA_MODEL · SYNC_CONTRACT · AI_INTEGRATION · SECURITY_MODEL · OBSERVABILITY · TESTING_STRATEGY · DEPLOYMENT_TOPOLOGY · FAILURE_MODES · LOCAL_DEV_SETUP · SERVICE_READINESS · SERVICE_RISK_REGISTER · MIGRATION_PLAN

Strategic anchors: 02 Enterprise Architecture · 04 Event-Driven Architecture · 05 API Design · 07 Security & Tenancy · 09 Lock & Key Integration · ADR-0004 Lock Abstraction · ADR-0003 Electron Offline-First Desktop

1. Purpose

lock-integration-service is the single physical-access integration boundary for Ghasi Melmastoon — the multi-tenant hotel SaaS platform whose backoffice is an Electron offline-first desktop and whose cloud is GCP. Behind one stable port (LockPort) it hides every vendor lock SDK, every BLE protocol, every USB encoder driver, and every offline-issuance edge case so the rest of the platform — reservation-service, staff-service, notification-service, the Electron renderer — never imports ttlock-sdk-js, salto-xs4-client, the Vostio REST client, serialport, node-hid, @abandonware/noble, or any other vendor- or hardware-specific dependency. This service exists for five reasons no other service can satisfy:

  1. One vendor boundary, one place. Each adapter (TtLockAdapter, SaltoAdapter, VostioAdapter, GenericWiegandAdapter, CloudProxyAdapter for desktop relay) lives under infrastructure/adapters/<vendor>/. Domain code never sees a vendor type, never receives a vendor error class, and never knows whether a credential was minted by BLE, REST, or USB. Onboarding a new vendor is a LockPort impl + a webhook receiver + a runbook + a 7-day pilot — not a release that touches reservation-service or the Electron renderer.
  2. Physical-access blast radius is the highest in the estate. A leaked vendor credential opens hotel doors. Per ADR-0004 §5, vendor API keys live in Secret Manager under lock/<tenant>/<property>/<vendor> and are readable only by this service's Cloud Run runtime identity. Per-property vendor configuration is encrypted at rest with a separate KMS key (melmastoon-lock/lock-config) from the platform default. No other service can read a vendor secret; no human has standing read access; break-glass requires dual approval and rotates the secret on close-out.
  3. Offline-first desktop must mint credentials when the cloud is gone. In target markets (Afghanistan, Tajikistan, Iran rural, Pakistan provinces) ISP links flap. The Electron main process holds a signed Ed25519 offline-issuance certificate (rotated every 90 days by this service) and a paired USB encoder or BLE-direct TTLock; when the cloud is unreachable it issues provisional: true credentials locally with a 48-hour hard cap. The cloud reconciler materializes or revokes them on next sync.
  4. Idempotency is a property, not a hope. Every saga step keys on sha256(reservationId + step + version). Vendor adapters are required to honor idempotencyKey — TTLock as clientNonce, Salto as X-Idempotency-Key, Vostio as Idempotency-Key, the Wiegand local adapter via SQLite-persisted dedupe. The CI chaos suite proves this with a 100×-replay test of every saga step.
  5. Audit immutability is regulatory. Hospitality regulators in GCC and parts of EU under EN 14846 require a tamper-evident physical-access audit trail for ≥5 years. We retain 7 years by default. Every lock.*.v1 event is consumed by audit-service and anchored daily into the Merkle root; the door-attempt stream from vendor webhooks is part of the same anchor.

2. Bounded context

Context name: Lock & Key Domain class: Core (this is a hotel-business differentiator, not a generic capability — lock integration is the highest-leverage operational lever in target markets per docs/09 §1) Ubiquitous language: KeyCredential, LockDevice, VendorAdapter, VendorCredential, MasterKey, Scope, KeyKindPolicy, OfflineIssuance, EncoderSession, ProvisionalCredential, VendorRef, AdapterCapabilities, KeyCredentialAttempt (door event), CircuitBreaker, IdempotencyKey, SignedOfflineCertificate.

What is in:

  • The LockPort interface and every vendor adapter implementing it.
  • The KeyCredential aggregate, lifecycle state machine, idempotent transitions.
  • The key-lifecycle saga (issue/update/suspend/revoke) driven by reservation.* and staff.shift.* events.
  • Lock device registry per property: vendor binding, room mapping, online state, battery, firmware, capabilities.
  • Vendor adapter registry, dispatch routing, per-(vendor, env) circuit breaker, health snapshot.
  • Vendor webhook intake (HMAC-verified, idempotent, dispatched to handlers).
  • Master-key issuance with role-bound, scope-bounded, time-bound credentials.
  • Signed offline-issuance certificate authority (Ed25519) for desktops.
  • The CloudProxy adapter that relays cloud-initiated USB-encoder operations to the Electron desktop's local adapter.
  • Immutable audit trail (Postgres hot store + BigQuery long-term sink + daily Merkle anchor).

What is out:

  • Reservation state, payment state, folio state — owned by the respective services; we are a downstream consumer.
  • Notification delivery (email/SMS/push of mobile-key links) — owned by notification-service; we publish events.
  • Staff identity, roles, shift schedules — owned by iam-service (MVP) and staff-service (Phase 1+); we consume their events.
  • The renderer-side UX of the desktop (key-status pills, replace-key modal, encoder pairing wizard) — owned by app-desktop-backoffice; we expose the IPC surface via the Electron main.
  • Door hardware itself — owned by the vendor.
  • The signed certificate's private key once it has been provisioned to the desktop — held in the OS keychain via keytar, never re-exfiltrated.

3. Aggregates owned

AggregateCardinalityPurposeIdentity prefix
KeyCredential1 per (reservation, kind) for guests; 1 per (staff, shift) for mastersLifecycle state, vendor ref, validity window, room scope, audit lineagekey_
KeyCredentialAttempt1 per door-access eventGranted/denied with reason; immutable, append-only, vendor-webhook-sourcedkca_
LockDevice1 per physical lock or door reader at a propertyVendor binding, room mapping, online status, battery, firmware, capabilitieslck_
VendorAdapter1 per (tenant, property, vendor)Adapter selection, environment, health snapshot, last error, circuit-breaker statevad_
VendorCredential1 per (tenant, property, vendor)Pointer into Secret Manager (secret_resource_name); never the secret bytes themselvesvcr_
EncoderSession1 per (desktop device, encoder) open sessionUSB/serial handle lifecycle owned by the Electron main processenc_
MasterKey1 per (staff, scope, shift)Role-bound, time-bound staff credentialmky_
KeyKindPolicy1 per (tenant, property)Tenant rules: preferred kinds order, fallback chain, ID-verify policy, max validity windowkkp_
OfflineIssuance1 per Ed25519 certSigned cert binding desktop → tenant + property + allowed kinds + max windowoki_

4. Responsibilities (numbered)

  1. Implement LockPort.issueCredential() — load VendorAdapter and KeyKindPolicy, dispatch to per-vendor adapter, persist intent + outbox + result, emit lock.credential.issued.v1.
  2. Implement LockPort.updateCredential() — extend or contract validUntil, swap rooms, refresh scope; idempotent on idempotencyKey.
  3. Implement LockPort.revokeCredential() — terminal transition; idempotent (second revoke is a no-op success); raises PagerDuty alert if vendor revoke fails (security category).
  4. Implement LockPort.suspendCredential() and LockPort.unsuspendCredential() — reversible state transitions for no-show, fraud-review, overdue-payment, manual.
  5. Implement LockPort.listDevices() and LockPort.healthCheck() — device registry queries with battery, firmware, clock skew.
  6. Implement LockPort.describeAdapter() — capability matrix surfaced to the saga and the desktop UI for kind-conditional UX.
  7. Key-lifecycle saga — subscribe to reservation.* and staff.shift.*; orchestrate issue/update/revoke/suspend; persist saga state outliving Cloud Run instance death; resume on consumer redelivery using idempotencyKey.
  8. Master-key issuance at shift start, automatic revoke at shift end; per-vendor recurring schedule used where supported, suspend/unsuspend cycle where not.
  9. Vendor webhook intake under /webhooks/v1/<vendor> — HMAC signature verification, persist to webhook_inbox keyed by (vendor, external_event_id) unique constraint, enqueue dispatcher; receiver never mutates business state.
  10. Vendor webhook dispatcher worker — drain webhook_inbox, route to per-vendor handler, apply state transition idempotently, emit lock.vendor_webhook.processed.v1 (or .duplicate.v1).
  11. Adapter circuit breaker — per (vendor, env) sliding window of last 100 calls; trip open at >25% errors or p99 > 5s for 60s; half-open after 30s; close after 10 successive successes. Emits lock.vendor_adapter.health_changed.v1.
  12. Offline issuance certificate authority — mint Ed25519 certs scoped (tenant, property, allowed kinds, max validity window, serial); 90-day rotation with 14-day overlap; CRL exposed via /api/v1/offline-issuance/certificates/revoked.
  13. CloudProxy adapter for Wiegand encoders — maintains an authenticated WebSocket relay to each property's Electron desktop main; cloud-initiated issue/revoke jobs are delivered over the relay and the desktop returns the result. If desktop unreachable, returns MELMASTOON.LOCK.CARD_ENCODER_OFFLINE (503) and the saga falls back per KeyKindPolicy.
  14. Reconciliation of provisional credentials — on /sync/v1/push from a desktop, materialize provisional: true credentials into the canonical store with server-assigned ULIDs, mapped via local_id → server_id; if reservation has changed (cancelled/dates_changed), revoke the provisional and issue/update accordingly.
  15. Audit lineage — every issue/update/suspend/revoke writes a row to lock_audit (Postgres) and emits an event consumed by audit-service (BigQuery + Merkle anchor).
  16. Secret-safe logging — adapter boundary scrubs vendor payloads before they reach the structured logger; CI scans logs for vendor-credential regexes; build fails on a hit.

5. Upstream / downstream context map

┌────────────────────┐
│ reservation-service│ reservation.confirmed/cancelled/checked_out/dates_changed/no_show.v1
└────────┬───────────┘

┌────────▼───────────┐ ┌─────────────────────┐
│ staff-service / │ │ iam-service │ staff.shift.started/ended.v1
│ iam-service │ │ │ iam.user.deactivated.v1
└────────┬───────────┘ └────────┬────────────┘
│ │
▼ ▼
┌────────────────────────────────────────────────┐
│ lock-integration-service │
│ │
│ saga ─► LockPort ─► [TtLock | Salto | Vostio │
│ | GenericWiegand │
│ | CloudProxy ↔ desktop] │
│ │
│ webhook intake ◄── TTLock / Salto / Vostio │
│ │
└────┬──────────────┬──────────────┬─────────────┘
│ │ │
lock.*.v1 ▼ lock.*.v1 ▼ lock.*.v1 ▼
┌──────────────┐ ┌─────────────┐ ┌──────────────┐
│ notification-│ │ audit- │ │ analytics- │
│ service │ │ service │ │ service │
│ (key delivery│ │ (Merkle │ │ (vendor cost │
│ templates) │ │ anchor) │ │ & anomalies)│
└──────────────┘ └─────────────┘ └──────────────┘

ai-orchestrator-service
(anomaly on attempts,
predictive battery)

6. Hotel-specific shape

  • Vendor mix in target markets — TTLock for guesthouses and BLE retrofits, Salto for mid-market chains, Vostio for enterprise rollouts, Generic Wiegand for legacy. The KeyKindPolicy per property declares the preferred kind order and fallback chain (e.g. mobile_app → pin_code → rfid_card → manual).
  • Mobile-key delivery is owned by notification-service consuming lock.credential.issued.v1. The key bytes never travel through the email or SMS channel; only keyCredentialId and a single-use exchange token, which the booking app exchanges for the artifact at bff-tenant-booking-service.
  • PIN-code fallback is a first-class kind. Many guests in target markets are on feature phones; PIN printed on the booking confirmation is the default for those.
  • Master-key scoping — chain operators with hundreds of rooms across multiple properties need dynamic scope (rooms:assigned computed from open housekeeping/maintenance tasks) and shift-bound time windows.
  • Offline issuance is operationally mandatory in regions with intermittent connectivity. Each tenant is provisioned with a signed Ed25519 cert per desktop at pairing; the cert's private key lives in the OS keychain via keytar.
  • PIN collision handling — TTLock and Salto both refuse duplicate PINs within a property scope. The adapter regenerates with cryptographic RNG up to 3 times before falling back to alternate kind per KeyKindPolicy.
  • Time skew on lock devices is a routine issue with battery-powered locks. healthCheck returns clockSkewMs; > 5 min surfaces a desktop warning, > 10 min raises a PagerDuty alert.
  • PCI parallel: vendor secrets are isolated like card secrets but the failure mode is physical, not financial. Rotation cadence is 90 days for all vendor OAuth/API secrets and the offline issuance cert.

7. Key non-functional commitments

SLOTargetWindowSource signal
lock.credential.issued.success_rate (cloud path)≥ 99.5%rolling 30d, per (vendor, property)saga outcome metric
lock.credential.issued.latency_p95_ms (cloud path)< 1.5s (TTLock), < 2s (Salto), < 1.5s (Vostio)rolling 7d, per vendoradapter span duration
lock.credential.revoked.success_rate≥ 99.9%rolling 30dsaga outcome metric
lock.vendor_webhook.dedupe_rate100% (zero double-application)alwaysinbox.spec.ts continuous
lock.audit.merkle_anchor.lag< 26hrolling 7daudit-service signal
Offline-issuance reconciliation success≥ 99.9%rolling 30d, per desktopsync push outcome
Saga step idempotencyexactly-once application on N replaysalwayschaos test (100× replay)

8. Key decisions (with rationale)

  1. One service, many adapters rather than per-vendor microservices — the saga is the hard part, not the SDK call. ADR-0004.
  2. LockPort is the lowest common denominator — vendor-specific capabilities are exposed via describeAdapter().capabilities flags and consumed by the saga conditionally. We pay the cost of an adapter abstraction in exchange for vendor portability for tenants.
  3. Renderer never touches lock APIs — non-negotiable per ADR-0003. CI checks the renderer bundle for vendor SDK / node-hid / serialport / @abandonware/noble imports; build fails on a hit.
  4. Vendor secrets in Secret Manager only, separate KMS key for vendor configuration ciphertext at rest. ADR-0004 §5.
  5. vendorRef is opaque and never logged or returned — treated as a credential. Adapter boundary scrubs.
  6. Offline issuance is provisional: true and capped at 48 hours — bounds the blast radius of an unrecoverable offline period.
  7. Revoke is idempotent and persisted as terminal even if vendor revoke fails — security-conservative; vendor failure raises an alert, but the platform considers the credential gone.
  8. Postgres advisory lock on (property_id, room_id, valid_from_date) prevents overlapping active credentials for the same room.
  9. Webhook receiver is dumb; dispatcher is smart — separates intake (signature + dedupe + persist) from state transitions, mirroring payment-gateway-service.
  10. Audit dual-write — Postgres for hot reads (per-credential audit endpoint) + BigQuery sink for 7-year retention + daily Merkle anchor.

9. Anti-goals

  • No direct vendor calls from reservation-service, staff-service, the renderer, or any other service. CI dependency check enforces. PRs that import a vendor SDK outside this service fail CI.
  • No exposure of vendorRef in any API response, sync payload, log line, event payload (other than the originating adapter's internal call). Treated as a credential.
  • No removal of immutable audit rows — even on tenant deletion, audit rows are pseudonymized after the legal retention window, never deleted.
  • No mutation of KeyCredential.id after issueid is stable; lost-key replacement creates a new id and links the old via replaced_by_id.
  • No multiple active credentials for the same (reservation, kind) — the advisory lock + a partial unique index enforce this.
  • No vendor SDK in the renderer, no USB/serial in the renderer, no BLE in the renderer.
  • No skipping pending → active — the saga must wait for explicit vendor confirmation before flipping to active.

10. Out-of-scope

  • Direct integration with consumer-grade smart locks (August, Yale Linus, etc.) — different threat model and SLA.
  • Vendors that require a permanent outbound tunnel to a vendor cloud we cannot inspect.
  • Lock vendor SaaS-as-a-middleware abstractions (e.g. third-party "lock unification" SaaS) — adds a third party to the most security-sensitive integration.
  • Door automation beyond credential issue/update/revoke (e.g. remote unlock via API) — Phase 4+ scoped feature, behind a separate port.
  • Camera and CCTV integration — separate service.

11. Versioning

  • API: /api/v1 stable; breaking changes ship /api/v2 with a 6-month deprecation window per 05 §3.
  • Events: subject melmastoon.lock.<aggregate>.<verb>.v<n>; breaking schema changes ship .v<n+1>, dual-publish for one full release, retire old.
  • LockPort interface: changes are an ADR-required event because they touch every adapter and the saga.

12. Cross-references