Skip to main content

APPLICATION_LOGIC — lock-integration-service

Bundle: SERVICE_OVERVIEW · DOMAIN_MODEL · API_CONTRACTS · EVENT_SCHEMAS · DATA_MODEL · SYNC_CONTRACT

Saga sequences live in docs/09 §7-§9. This file specifies the use cases, ports, and orchestration steps that implement them.

The application layer is pure orchestration. It composes domain aggregates with infrastructure ports. It contains no NestJS decorators on its core types (use cases are plain classes/functions); it contains no SQL, no fetch, no vendor SDKs.

1. Ports (interfaces declared by the application layer, implemented by infrastructure)

src/application/ports/
├── lock.port.ts LockPort (canonical, see DOMAIN_MODEL §6)
├── key-credential.repository.port.ts
├── key-credential-attempt.repository.port.ts
├── lock-device.repository.port.ts
├── vendor-adapter.repository.port.ts
├── vendor-credential.repository.port.ts
├── master-key.repository.port.ts
├── key-kind-policy.repository.port.ts
├── encoder-session.repository.port.ts
├── offline-issuance.repository.port.ts
├── outbox.port.ts EventPublisherPort (transactional outbox)
├── inbox.port.ts InboxDedupePort
├── secret-fetcher.port.ts SecretFetcher (Secret Manager)
├── kms-encryptor.port.ts KmsEncryptor (lock-config CMEK key)
├── advisory-lock.port.ts AdvisoryLockPort (PG advisory locks)
├── ws-relay.port.ts WsRelayPort (cloud → desktop CloudProxy)
├── clock.port.ts Clock (testable now())
└── id-generator.port.ts IdGenerator (ULID minting)

Each port has a kebab-case file name and a PascalCase interface; adapters live under infrastructure/adapters/<name>/ (NAMING).

2. Use cases

One file per use case under src/application/use-cases/<verb-noun>.use-case.ts. Each use case is a class with a single execute() method, accepts a typed command, returns a typed result, and emits domain events through the aggregate's pending-events list.

Use caseTriggered byCommandReturns
IssueKeyCredentialUseCaseSaga step on reservation.confirmed.v1 and direct API POST /api/v1/key-credentialsIssueKeyCredentialCommandIssueResult
UpdateKeyCredentialUseCaseSaga step on reservation.dates_changed.v1 and PATCH /api/v1/key-credentials/:idUpdateKeyCredentialCommandUpdateResult
RevokeKeyCredentialUseCaseSaga step on reservation.cancelled.v1, reservation.checked_out.v1, and POST /:id/revokeRevokeKeyCredentialCommandvoid
SuspendKeyCredentialUseCaseSaga step on reservation.no_show.v1, reservation.fraud_flagged.v1, POST /:id/suspendSuspendKeyCredentialCommandvoid
UnsuspendKeyCredentialUseCasePOST /:id/unsuspendUnsuspendKeyCredentialCommandvoid
ReplaceKeyCredentialUseCaseLost-key flow POST /:id/replaceReplaceKeyCredentialCommandIssueResult (new credential)
IssueMasterKeyUseCaseSaga on staff.shift.started.v1, POST /api/v1/master-keysIssueMasterKeyCommandIssueResult
RevokeMasterKeyUseCaseSaga on staff.shift.ended.v1, POST /api/v1/master-keys/:id/revokeRevokeMasterKeyCommandvoid
RegisterLockDeviceUseCaseDesktop pairing wizardRegisterLockDeviceCommandLockDevice
ProbeDeviceHealthUseCaseAPI + scheduled jobProbeDeviceHealthCommandDeviceHealth
IngestVendorWebhookUseCaseWebhook controllerIngestVendorWebhookCommandvoid
DispatchVendorWebhookUseCaseWorker dequeueDispatchVendorWebhookCommandvoid
ReconcileProvisionalCredentialsUseCaseSync push handlerReconcileProvisionalCredentialsCommandReconciliationReport
MintOfflineIssuanceCertificateUseCaseDesktop pairing wizardMintOfflineIssuanceCertificateCommandOfflineIssuance + privateKeyHandoff
RevokeOfflineIssuanceCertificateUseCaseRotation/compromiseRevokeOfflineIssuanceCertificateCommandvoid

3. The key-lifecycle saga

The saga is a long-running orchestration implemented as a state machine over the inbox. There is no separate workflow engine — saga state is persisted in key_credential_saga keyed by (reservation_id, saga_step) so that any consumer redelivery resumes from the last persisted step.

3.1 Issue saga — happy path

reservation.confirmed.v1


┌───────────────────────────────────────────────────────────────────────┐
│ IssueSaga.handle(event) │
│ │
│ step 1 load KeyKindPolicy(propertyId) │
│ step 2 load VendorAdapter(propertyId, policy.vendor) │
│ step 3 AdvisoryLockPort.acquire(`lock:${propertyId}:${roomId}`) │
│ │ │
│ │ on conflict → defer 250ms; max 5 attempts │
│ ▼ │
│ step 4 IssueKeyCredentialUseCase.execute({ │
│ tenantId, propertyId, reservationId, rooms, │
│ validFrom, validUntil, guest, │
│ preferredKinds: policy.preferredOrder, │
│ idempotencyKey: sha256(reservationId+'issue'+'v1'), │
│ }) │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────┐ │
│ │ KeyCredentialAggregate.request() │ │
│ │ → state = 'requested' │ │
│ │ → pendingEvents += KeyCredentialRequested│ │
│ │ repo.save() + outbox.enqueue() (one TX) │ │
│ └──────────────┬───────────────────────────┘ │
│ ▼ │
│ ┌──────────────────────────────────────────┐ │
│ │ LockPort.issueCredential(input) │ │
│ │ adapter selected by VendorAdapter row │ │
│ │ adapter honors idempotencyKey │ │
│ └──────────────┬───────────────────────────┘ │
│ │ │
│ vendor returns IssueResult │
│ ▼ │
│ ┌──────────────────────────────────────────┐ │
│ │ agg.markPending({vendorRef}) │ │
│ │ agg.markActive(issuedAt) │ │
│ │ → pendingEvents += KeyCredentialIssued │ │
│ │ repo.save() + outbox.enqueue() (one TX) │ │
│ └──────────────┬───────────────────────────┘ │
│ │ │
│ step 5 AdvisoryLockPort.release(...) │
│ step 6 inbox.markProcessed(eventId, 'lock-integration-issue-saga') │
└───────────────────────────────────────────────────────────────────────┘


melmastoon.lock.credential.issued.v1 (Pub/Sub topic = subject)


notification-service consumes → mobile-key/PIN/RFID delivery template
audit-service consumes → Merkle anchor entry

3.2 Issue saga — failure paths and compensations

FailureDetectionSaga response
LockError.VENDOR_UNREACHABLEadapterretry with exponential backoff: [500ms, 1s, 2s, 4s, 8s] ± jitter; after 5 attempts → markFailed('vendor_unreachable'); emit lock.credential.failed.v1; saga step succeeds (idempotent); reservation flow unaffected; manual override path opens in desktop
LockError.KEY_ISSUE_FAILED (non-retriable per retriable: true flag — adapter signal)adapterretry up to 3; then markFailed('vendor_refused'); emit failed
pin_collision (vendor returns dup PIN error)adapter normalizes to KEY_ISSUE_FAILED with sub-reasonregenerate PIN with crypto.randomInt; max 3 attempts; then fall back to next kind in policy.fallbackChain
kind_unsupported (current adapter lacks capability)describeAdapter().capabilities checkdrop the unsupported kind from preferredKinds and re-enter use case
cancelled_mid_flight (reservation.cancelled arrives while issue saga in progress)inbox correlationif state ∈ {requested, pending}: markFailed('cancelled_mid_flight'). If state === active: immediately enter revoke saga
Adapter crash mid-call (Cloud Run instance dies)Pub/Sub NACK + redeliverysaga state outlives the instance; on redelivery, the use case sees existing idempotencyKey, the vendor sees the same clientNonce, the result is one logical issuance
Postgres advisory lock contention (concurrent issue for same room)AdvisoryLockPort.acquire returns falseretry up to 5×; then surface MELMASTOON.GENERAL.RESOURCE_NOT_FOUND (409 in API path) — operationally rare

3.3 Revoke saga — sequence

reservation.cancelled.v1 (or .checked_out.v1 / .early_checkout.v1)


┌────────────────────────────────────────────────────────────────────┐
│ RevokeSaga.handle(event) │
│ │
│ step 1 repo.findByReservationId(reservationId) → KeyCredential[]│
│ step 2 for each credential where state ∈ {pending, active, │
│ suspended}: │
│ RevokeKeyCredentialUseCase.execute({ │
│ keyCredentialId, reason: <event-derived>, │
│ idempotencyKey: sha256(eventId+id+'revoke'), │
│ }) │
│ │ │
│ ▼ │
│ agg.revoke(reason) │
│ persist (state=revoked) + outbox: revoked.v1 (TX) │
│ try LockPort.revokeCredential(id, reason, idemKey): │
│ success → done │
│ failure → audit row 'KEY_REVOKE_FAILED'; │
│ PagerDuty alert (security category); │
│ state remains 'revoked' in our store │
│ step 3 inbox.markProcessed(eventId, 'lock-integration-revoke') │
└────────────────────────────────────────────────────────────────────┘

Invariant: the platform considers the credential revoked the instant the revoked state is persisted. Vendor-side propagation is best-effort. Any subsequent door access logged via webhook is recorded as an audit anomaly (KeyCredentialAttempt with outcome=granted after state=revoked triggers an alert).

3.4 Update saga — sequence

reservation.dates_changed.v1 carries oldCheckOut, newCheckOut, optionally oldRoomId/newRoomId. The use case:

  1. Loads all state ∈ {pending, active} credentials for the reservation.
  2. For each: agg.update({ validUntil: newCheckOut, rooms: <new if changed> }).
  3. Persists, enqueues KeyCredentialUpdated to outbox.
  4. Calls LockPort.updateCredential({ keyCredentialId, validUntil, rooms?, idempotencyKey }).
  5. If vendor lacks updateCredential (capability remoteIssue: false): revoke + re-issue chain.

3.5 Suspend / unsuspend saga — sequence

reservation.no_show.v1 triggers suspend after policy.noShowSuspendAfterHours (default 2h after validFrom). The saga schedules a delayed message via Cloud Tasks; on fire, it re-checks reservation state (still no_show?) and then calls SuspendKeyCredentialUseCase.

Unsuspend is exclusively manual via API or via reservation.no_show.cleared.v1 (Phase 1+).

3.6 Master-key shift saga — sequence

staff.shift.started.v1 { staffUserId, propertyId, scope, validFrom, validUntil }


┌────────────────────────────────────────────────────────────────────┐
│ MasterKeyShiftSaga.handleStart(event) │
│ │
│ step 1 load KeyKindPolicy(propertyId) → choose kind for staff │
│ (default: rfid_card if cardEncoding else mobile_app) │
│ step 2 IssueMasterKeyUseCase.execute({ │
│ staffUserId, propertyId, scope, │
│ validFrom, validUntil, │
│ kind: chosenKind, │
│ idempotencyKey: sha256(shiftId+'issue-master') │
│ }) │
│ emits KeyCredentialIssued + lock.master_key.issued.v1 │
│ step 3 inbox.markProcessed │
└────────────────────────────────────────────────────────────────────┘

staff.shift.ended.v1


┌────────────────────────────────────────────────────────────────────┐
│ MasterKeyShiftSaga.handleEnd(event) │
│ 1. find MasterKey by shiftId │
│ 2. RevokeMasterKeyUseCase.execute({ id, reason: 'checkout', │
│ idempotencyKey: sha256(shiftId+'revoke-master') }) │
│ 3. emits lock.master_key.expired.v1 │
└────────────────────────────────────────────────────────────────────┘

3.7 Vendor webhook saga — intake + dispatch

POST /webhooks/v1/<vendor>


WebhookController.handle(req)
1. WebhookSignatureVerifier.verify(req, vendorSecret)
on fail → 401, MELMASTOON.LOCK.WEBHOOK_SIGNATURE_INVALID
2. parse body to canonical { vendor, externalEventId, type, payload }
3. webhookInboxRepo.insert({...}) ON CONFLICT (vendor, external_event_id) DO NOTHING
if conflict → 200 ack ('duplicate dropped')
4. cloud-tasks.enqueue('webhook.dispatch', { rowId })
5. 202 Accepted


WebhookDispatcherWorker.process(rowId)
1. row = webhookInboxRepo.lockForUpdate(rowId) -- skip if already processed
2. handler = registry.lookup(vendor, type)
3. handler.apply(row.payload):
- 'access.granted' / 'access.denied' → KeyCredentialAttemptRepository.append(...)
- 'credential.activated' → agg.markActive(issuedAt) if pending
- 'credential.revoked' → agg.revoke('replaced') if not already
- 'device.battery_low' → LockDevice.updateBattery(...) + emit
- 'device.offline' / 'device.online' → LockDevice.updateOnline(...) + emit
4. row.markProcessed()
5. emit melmastoon.lock.vendor_webhook.processed.v1

3.8 Offline-issuance reconciliation — sequence

Reconciliation runs as part of the /sync/v1/push handler when the desktop pushes locally-issued credentials. See SYNC_CONTRACT for the conflict policy.

sync push from desktop {
outbox: [
{ kind: 'lock.credential.issued.local.v1',
payload: { localId, tenantId, propertyId, reservationId, kind,
rooms, validFrom, validUntil, vendorRef, certSerial, ... } },
...
]
}


ReconcileProvisionalCredentialsUseCase.execute(batch)
for each item:
1. verify signed cert(certSerial) → valid + not revoked
else → reject; emit lock.credential.failed.v1; instruct desktop to revoke locally
2. lookup reservation by reservationId:
- if not found OR state ∈ {cancelled, no_show}:
→ call RevokeKeyCredentialUseCase against vendor (best-effort)
→ persist revoked credential locally
→ emit lock.credential.revoked.v1 with metadata.wasProvisional=true
- if confirmed and matches:
→ mint server-assigned KeyCredentialId
→ upsert KeyCredential (state=active, provisional=false)
→ record { localId → serverId } in id_map for desktop pull
→ emit lock.credential.issued.v1 (canonical)
3. enqueue audit row + Pub/Sub
return ReconciliationReport { reconciled, revoked, failed }


sync push response includes id_map so desktop can renumber its local rows

4. Adapter dispatch flow

LockPort is a dispatcher façade in the application layer. Implementation in infrastructure:

// src/infrastructure/adapters/lock-port-dispatcher.adapter.ts
export class LockPortDispatcher implements LockPort {
constructor(
private readonly registry: VendorAdapterRegistry,
private readonly vendorRepo: VendorAdapterRepositoryPort,
private readonly credRepo: VendorCredentialRepositoryPort,
private readonly secrets: SecretFetcher,
private readonly breaker: CircuitBreakerRegistry,
private readonly telemetry: AdapterTelemetry,
) {}

async issueCredential(input: IssueInput): Promise<IssueResult> {
const adapterRow = await this.vendorRepo.findFor(input.tenantId, input.propertyId);
const breaker = this.breaker.for(adapterRow);
if (breaker.state === 'open') {
throw new VendorUnreachableError(adapterRow.vendor, 'circuit_open');
}
const port = await this.registry.load(adapterRow, this.secrets, this.telemetry);
return breaker.exec(() => port.issueCredential(input));
}

// updateCredential, revokeCredential, suspendCredential, etc. follow the same shape.
}

The per-vendor adapter (e.g. TtLockAdapter) implements LockPort against the vendor's wire protocol and translates errors into LockError. Adapters are loaded lazily and cached per (tenantId, propertyId, vendor) for the lifetime of the Cloud Run instance.

5. Outbox + inbox

This service uses the standard transactional outbox/inbox pattern from 04 §5:

  • Outbox. Every aggregate save in a use case writes domain events to outbox in the same Postgres transaction. A outbox-relay.adapter.ts worker polls and publishes to Pub/Sub, marking rows published_at.
  • Inbox. Every consumed event is recorded in inbox with (message_id, consumer_name) unique constraint; second deliveries are no-ops at the use-case boundary.
  • The mandatory tests/integration/outbox.spec.ts and inbox.spec.ts gate readiness (SERVICE_TEMPLATE §Mandatory tests).

6. Idempotency keys

Per docs/09 §7.3, every saga step keys idempotency by sha256(reservationId + step + version) (or shiftId + step + version for masters). Vendor adapters honor it as their native nonce header. The application layer:

  1. Computes the key once at saga step entry.
  2. Passes it to the use case command.
  3. The use case persists the key on the aggregate (column idempotency_key).
  4. The adapter forwards it to the vendor.
  5. On replay, the use case detects the existing aggregate with the same key and returns the existing IssueResult without calling the vendor.

7. Capability-conditional branching

Before dispatch, the saga consults LockPort.describeAdapter().capabilities:

preferredKinds = policy.preferredOrder.filter(kind => {
switch (kind) {
case 'mobile_app': return capabilities.mobileKey;
case 'pin_code': return capabilities.pin;
case 'rfid_card': return capabilities.cardEncoding;
case 'qr_code': return capabilities.qr;
case 'nfc_tag': return capabilities.nfc;
}
})
if preferredKinds.length === 0:
→ fallback to policy.fallbackChain
→ if still empty: emit lock.credential.failed.v1 with 'kind_unsupported'

For master-key scope:

  • scope.kind === 'floor' requires capabilities.scopeFloors.
  • scope.kind === 'areas' requires capabilities.scopeAreas.
  • Where missing, the saga expands to per-room enumeration using property-service data.

8. Observability touchpoints

Every use case wraps its body in an OpenTelemetry span: lock.usecase.<name> with attributes tenant_id, property_id, reservation_id?, key_credential_id?, vendor, idempotency_key_hash (truncated 8 chars), result. Adapter calls add a child span lock.adapter.<vendor>.<method>. Errors set the span status and attach the canonical error code.

See OBSERVABILITY for the full SLI/SLO catalog and dashboard layout.