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 case | Triggered by | Command | Returns |
|---|---|---|---|
IssueKeyCredentialUseCase | Saga step on reservation.confirmed.v1 and direct API POST /api/v1/key-credentials | IssueKeyCredentialCommand | IssueResult |
UpdateKeyCredentialUseCase | Saga step on reservation.dates_changed.v1 and PATCH /api/v1/key-credentials/:id | UpdateKeyCredentialCommand | UpdateResult |
RevokeKeyCredentialUseCase | Saga step on reservation.cancelled.v1, reservation.checked_out.v1, and POST /:id/revoke | RevokeKeyCredentialCommand | void |
SuspendKeyCredentialUseCase | Saga step on reservation.no_show.v1, reservation.fraud_flagged.v1, POST /:id/suspend | SuspendKeyCredentialCommand | void |
UnsuspendKeyCredentialUseCase | POST /:id/unsuspend | UnsuspendKeyCredentialCommand | void |
ReplaceKeyCredentialUseCase | Lost-key flow POST /:id/replace | ReplaceKeyCredentialCommand | IssueResult (new credential) |
IssueMasterKeyUseCase | Saga on staff.shift.started.v1, POST /api/v1/master-keys | IssueMasterKeyCommand | IssueResult |
RevokeMasterKeyUseCase | Saga on staff.shift.ended.v1, POST /api/v1/master-keys/:id/revoke | RevokeMasterKeyCommand | void |
RegisterLockDeviceUseCase | Desktop pairing wizard | RegisterLockDeviceCommand | LockDevice |
ProbeDeviceHealthUseCase | API + scheduled job | ProbeDeviceHealthCommand | DeviceHealth |
IngestVendorWebhookUseCase | Webhook controller | IngestVendorWebhookCommand | void |
DispatchVendorWebhookUseCase | Worker dequeue | DispatchVendorWebhookCommand | void |
ReconcileProvisionalCredentialsUseCase | Sync push handler | ReconcileProvisionalCredentialsCommand | ReconciliationReport |
MintOfflineIssuanceCertificateUseCase | Desktop pairing wizard | MintOfflineIssuanceCertificateCommand | OfflineIssuance + privateKeyHandoff |
RevokeOfflineIssuanceCertificateUseCase | Rotation/compromise | RevokeOfflineIssuanceCertificateCommand | void |
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
| Failure | Detection | Saga response |
|---|---|---|
LockError.VENDOR_UNREACHABLE | adapter | retry 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) | adapter | retry up to 3; then markFailed('vendor_refused'); emit failed |
pin_collision (vendor returns dup PIN error) | adapter normalizes to KEY_ISSUE_FAILED with sub-reason | regenerate PIN with crypto.randomInt; max 3 attempts; then fall back to next kind in policy.fallbackChain |
kind_unsupported (current adapter lacks capability) | describeAdapter().capabilities check | drop the unsupported kind from preferredKinds and re-enter use case |
cancelled_mid_flight (reservation.cancelled arrives while issue saga in progress) | inbox correlation | if 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 + redelivery | saga 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 false | retry 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:
- Loads all
state ∈ {pending, active}credentials for the reservation. - For each:
agg.update({ validUntil: newCheckOut, rooms: <new if changed> }). - Persists, enqueues
KeyCredentialUpdatedto outbox. - Calls
LockPort.updateCredential({ keyCredentialId, validUntil, rooms?, idempotencyKey }). - If vendor lacks
updateCredential(capabilityremoteIssue: 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
outboxin the same Postgres transaction. Aoutbox-relay.adapter.tsworker polls and publishes to Pub/Sub, marking rowspublished_at. - Inbox. Every consumed event is recorded in
inboxwith(message_id, consumer_name)unique constraint; second deliveries are no-ops at the use-case boundary. - The mandatory
tests/integration/outbox.spec.tsandinbox.spec.tsgate 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:
- Computes the key once at saga step entry.
- Passes it to the use case command.
- The use case persists the key on the aggregate (column
idempotency_key). - The adapter forwards it to the vendor.
- On replay, the use case detects the existing aggregate with the same key and returns the existing
IssueResultwithout 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'requirescapabilities.scopeFloors.scope.kind === 'areas'requirescapabilities.scopeAreas.- Where missing, the saga expands to per-room enumeration using
property-servicedata.
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.