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:
- One vendor boundary, one place. Each adapter (
TtLockAdapter,SaltoAdapter,VostioAdapter,GenericWiegandAdapter,CloudProxyAdapterfor desktop relay) lives underinfrastructure/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 aLockPortimpl + a webhook receiver + a runbook + a 7-day pilot — not a release that touchesreservation-serviceor the Electron renderer. - 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. - 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: truecredentials locally with a 48-hour hard cap. The cloud reconciler materializes or revokes them on next sync. - Idempotency is a property, not a hope. Every saga step keys on
sha256(reservationId + step + version). Vendor adapters are required to honoridempotencyKey— TTLock asclientNonce, Salto asX-Idempotency-Key, Vostio asIdempotency-Key, the Wiegand local adapter via SQLite-persisted dedupe. The CI chaos suite proves this with a 100×-replay test of every saga step. - 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.*.v1event is consumed byaudit-serviceand 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
LockPortinterface and every vendor adapter implementing it. - The
KeyCredentialaggregate, lifecycle state machine, idempotent transitions. - The key-lifecycle saga (issue/update/suspend/revoke) driven by
reservation.*andstaff.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
CloudProxyadapter 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) andstaff-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
| Aggregate | Cardinality | Purpose | Identity prefix |
|---|---|---|---|
KeyCredential | 1 per (reservation, kind) for guests; 1 per (staff, shift) for masters | Lifecycle state, vendor ref, validity window, room scope, audit lineage | key_ |
KeyCredentialAttempt | 1 per door-access event | Granted/denied with reason; immutable, append-only, vendor-webhook-sourced | kca_ |
LockDevice | 1 per physical lock or door reader at a property | Vendor binding, room mapping, online status, battery, firmware, capabilities | lck_ |
VendorAdapter | 1 per (tenant, property, vendor) | Adapter selection, environment, health snapshot, last error, circuit-breaker state | vad_ |
VendorCredential | 1 per (tenant, property, vendor) | Pointer into Secret Manager (secret_resource_name); never the secret bytes themselves | vcr_ |
EncoderSession | 1 per (desktop device, encoder) open session | USB/serial handle lifecycle owned by the Electron main process | enc_ |
MasterKey | 1 per (staff, scope, shift) | Role-bound, time-bound staff credential | mky_ |
KeyKindPolicy | 1 per (tenant, property) | Tenant rules: preferred kinds order, fallback chain, ID-verify policy, max validity window | kkp_ |
OfflineIssuance | 1 per Ed25519 cert | Signed cert binding desktop → tenant + property + allowed kinds + max window | oki_ |
4. Responsibilities (numbered)
- Implement
LockPort.issueCredential()— loadVendorAdapterandKeyKindPolicy, dispatch to per-vendor adapter, persist intent + outbox + result, emitlock.credential.issued.v1. - Implement
LockPort.updateCredential()— extend or contractvalidUntil, swaprooms, refreshscope; idempotent onidempotencyKey. - Implement
LockPort.revokeCredential()— terminal transition; idempotent (second revoke is a no-op success); raises PagerDuty alert if vendor revoke fails (security category). - Implement
LockPort.suspendCredential()andLockPort.unsuspendCredential()— reversible state transitions for no-show, fraud-review, overdue-payment, manual. - Implement
LockPort.listDevices()andLockPort.healthCheck()— device registry queries with battery, firmware, clock skew. - Implement
LockPort.describeAdapter()— capability matrix surfaced to the saga and the desktop UI for kind-conditional UX. - Key-lifecycle saga — subscribe to
reservation.*andstaff.shift.*; orchestrate issue/update/revoke/suspend; persist saga state outliving Cloud Run instance death; resume on consumer redelivery usingidempotencyKey. - Master-key issuance at shift start, automatic revoke at shift end; per-vendor recurring schedule used where supported, suspend/unsuspend cycle where not.
- Vendor webhook intake under
/webhooks/v1/<vendor>— HMAC signature verification, persist towebhook_inboxkeyed by(vendor, external_event_id)unique constraint, enqueue dispatcher; receiver never mutates business state. - Vendor webhook dispatcher worker — drain
webhook_inbox, route to per-vendor handler, apply state transition idempotently, emitlock.vendor_webhook.processed.v1(or.duplicate.v1). - 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. - 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. CloudProxyadapter 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, returnsMELMASTOON.LOCK.CARD_ENCODER_OFFLINE(503) and the saga falls back perKeyKindPolicy.- Reconciliation of provisional credentials — on
/sync/v1/pushfrom a desktop, materializeprovisional: truecredentials into the canonical store with server-assigned ULIDs, mapped vialocal_id → server_id; if reservation has changed (cancelled/dates_changed), revoke the provisional and issue/update accordingly. - Audit lineage — every issue/update/suspend/revoke writes a row to
lock_audit(Postgres) and emits an event consumed byaudit-service(BigQuery + Merkle anchor). - 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
KeyKindPolicyper 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-serviceconsuminglock.credential.issued.v1. The key bytes never travel through the email or SMS channel; onlykeyCredentialIdand a single-use exchange token, which the booking app exchanges for the artifact atbff-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:assignedcomputed 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.
healthCheckreturnsclockSkewMs; > 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
| SLO | Target | Window | Source 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 vendor | adapter span duration |
lock.credential.revoked.success_rate | ≥ 99.9% | rolling 30d | saga outcome metric |
lock.vendor_webhook.dedupe_rate | 100% (zero double-application) | always | inbox.spec.ts continuous |
lock.audit.merkle_anchor.lag | < 26h | rolling 7d | audit-service signal |
| Offline-issuance reconciliation success | ≥ 99.9% | rolling 30d, per desktop | sync push outcome |
| Saga step idempotency | exactly-once application on N replays | always | chaos test (100× replay) |
8. Key decisions (with rationale)
- One service, many adapters rather than per-vendor microservices — the saga is the hard part, not the SDK call. ADR-0004.
LockPortis the lowest common denominator — vendor-specific capabilities are exposed viadescribeAdapter().capabilitiesflags and consumed by the saga conditionally. We pay the cost of an adapter abstraction in exchange for vendor portability for tenants.- Renderer never touches lock APIs — non-negotiable per ADR-0003. CI checks the renderer bundle for vendor SDK /
node-hid/serialport/@abandonware/nobleimports; build fails on a hit. - Vendor secrets in Secret Manager only, separate KMS key for vendor configuration ciphertext at rest. ADR-0004 §5.
vendorRefis opaque and never logged or returned — treated as a credential. Adapter boundary scrubs.- Offline issuance is
provisional: trueand capped at 48 hours — bounds the blast radius of an unrecoverable offline period. - 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.
- Postgres advisory lock on
(property_id, room_id, valid_from_date)prevents overlapping active credentials for the same room. - Webhook receiver is dumb; dispatcher is smart — separates intake (signature + dedupe + persist) from state transitions, mirroring
payment-gateway-service. - 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
vendorRefin 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.idafter issue —idis stable; lost-key replacement creates a new id and links the old viareplaced_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 toactive.
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/v1stable; breaking changes ship/api/v2with 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. LockPortinterface: changes are an ADR-required event because they touch every adapter and the saga.
12. Cross-references
- docs/09 — Lock & Key Integration — canonical specification
- docs/architecture/ADR-0004 — architectural rationale
- docs/architecture/ADR-0003 — Electron renderer/main isolation
- docs/standards/ERROR_CODES — LOCK — canonical error registry
- docs/standards/NAMING — naming, ID prefixes, event subjects