DOMAIN_MODEL — lock-integration-service
Bundle: SERVICE_OVERVIEW · APPLICATION_LOGIC · API_CONTRACTS · EVENT_SCHEMAS · DATA_MODEL
Source of truth for the port shape: docs/09 §4 LockPort interface. This document specializes the canonical port into the full per-vendor adapter signatures and the aggregate model.
The domain layer is pure TypeScript. It does not import NestJS, Drizzle, Pub/Sub, fetch, or any vendor SDK. All I/O is behind ports defined in application/ports/ (see APPLICATION_LOGIC).
1. Aggregates and entities
1.1 KeyCredential — root aggregate
The KeyCredential is the single source of truth for one logical credential's lifetime: who holds it, what rooms it opens, when it is valid, what state it is in, which vendor minted it, and what its replacement lineage is.
import type {
TenantId, PropertyId, ReservationId, RoomId, GuestId, UserId, ISODate, Brand,
} from '@ghasi/shared/types';
export type KeyCredentialId = Brand<string, 'KeyCredentialId'>;
export type LockDeviceId = Brand<string, 'LockDeviceId'>;
export type VendorAdapterId = Brand<string, 'VendorAdapterId'>;
export type EncoderSessionId = Brand<string, 'EncoderSessionId'>;
export type MasterKeyId = Brand<string, 'MasterKeyId'>;
export type OfflineIssuanceId = Brand<string, 'OfflineIssuanceId'>;
export type KeyCredentialKind =
| 'mobile_app' | 'pin_code' | 'rfid_card' | 'qr_code' | 'nfc_tag';
export type KeyCredentialState =
| 'requested' | 'pending' | 'active' | 'suspended' | 'revoked' | 'failed';
export type KeyHolderKind = 'guest' | 'staff_master';
export type SuspendReason = 'no_show' | 'fraud_review' | 'overdue_payment' | 'manual';
export type RevokeReason = 'checkout' | 'cancellation' | 'security' | 'lost' | 'replaced';
export type FailureReason = 'vendor_unreachable' | 'vendor_refused' | 'pin_collision_exhausted'
| 'no_capable_device' | 'kind_unsupported' | 'cancelled_mid_flight';
export type VendorCode = 'ttlock' | 'salto' | 'assa-abloy' | 'generic-wiegand';
export interface KeyCredentialScope {
floors?: string[];
areas?: ('lobby' | 'gym' | 'pool' | 'spa' | 'parking' | 'back_of_house')[];
}
export interface KeyCredential {
readonly id: KeyCredentialId;
readonly tenantId: TenantId;
readonly propertyId: PropertyId;
readonly holderKind: KeyHolderKind;
readonly reservationId?: ReservationId; // present when holderKind = 'guest'
readonly guestId?: GuestId;
readonly staffUserId?: UserId; // present when holderKind = 'staff_master'
readonly kind: KeyCredentialKind;
readonly rooms: RoomId[]; // empty for masters with `areas`-only scope
readonly scope: KeyCredentialScope;
readonly validFrom: ISODate;
readonly validUntil: ISODate;
readonly state: KeyCredentialState;
readonly vendor: VendorCode;
readonly vendorAdapterId: VendorAdapterId;
readonly vendorRef: string; // opaque; never serialized off-aggregate
readonly provisional: boolean; // true only when minted on Electron offline
readonly idempotencyKey: string;
readonly issuedAt?: ISODate;
readonly suspendedAt?: ISODate;
readonly revokedAt?: ISODate;
readonly failureReason?: FailureReason;
readonly suspendReason?: SuspendReason;
readonly revokeReason?: RevokeReason;
readonly replacesId?: KeyCredentialId; // lineage on replacement
readonly replacedById?: KeyCredentialId;
readonly version: number; // optimistic concurrency
readonly createdAt: ISODate;
readonly updatedAt: ISODate;
}
1.2 KeyCredential invariants
- I-1.
validFrom < validUntil. - I-2.
holderKind === 'guest'⇒reservationId !== undefined && guestId !== undefined && staffUserId === undefined. - I-3.
holderKind === 'staff_master'⇒staffUserId !== undefined && reservationId === undefined. - I-4.
state === 'revoked'is terminal: no transition out ofrevoked. Re-issue creates a newidwithreplacesIdset. - I-5.
state === 'failed'is terminal: no transition out offailed. A retry creates a new credential. - I-6.
kind === 'rfid_card'⇒ vendordescribeAdapter().capabilities.cardEncoding === true. - I-7.
provisional === true⇒ minted by the Electron offline adapter;validUntil <= validFrom + 48h; reconciler must materialize or revoke on next sync. - I-8.
vendorRefis never present in any DTO returned to consumers, never logged, never replicated to desktop SQLite. - I-9. No two
state === 'active'credentials may overlap on(tenantId, propertyId, roomId, [validFrom, validUntil])— enforced by Postgres advisory lock during issue and a partial unique index on thekey_credential_roomsjoin table (see DATA_MODEL §4). - I-10. Only
KeyCredentialaggregate methods may transition state; transitions emit domain events to the aggregate's pending event list and are flushed by the application layer through the outbox. - I-11. Cross-tenant references are forbidden — every
RoomId,ReservationId,GuestId,UserIdis checked against the sametenantIdat construction; violation throwsCrossTenantReferenceError(MELMASTOON.GENERAL.CROSS_TENANT_REFERENCE, 422). - I-12.
rooms.length > 0for guest credentials. Master credentials may have emptyroomsifscope.areascovers only common areas.
1.3 KeyCredential state machine
The full diagram lives in docs/09 §6.1. The aggregate exposes one method per legal transition; each method takes the inputs it needs plus an idempotencyKey and the actor (saga step or operator):
export class KeyCredentialAggregate {
static request(input: IssueIntent): KeyCredentialAggregate; // → requested
markPending(vendorAck: { vendorRef: string }): void; // requested → pending
markActive(issuedAt: ISODate): void; // pending → active
markFailed(reason: FailureReason, vendorMessage?: string): void; // requested|pending → failed
suspend(reason: SuspendReason, idempotencyKey: string): void; // active → suspended
unsuspend(idempotencyKey: string): void; // suspended → active
revoke(reason: RevokeReason, idempotencyKey: string): void; // active|suspended|pending → revoked
update(input: UpdateInput): void; // active|suspended (validUntil/rooms/scope)
linkReplacement(newId: KeyCredentialId): void; // sets replacedById; only when state === 'revoked' and reason === 'lost' or 'replaced'
pendingEvents(): readonly DomainEvent[];
clearPendingEvents(): void;
}
1.4 KeyCredentialAttempt — door-access event (immutable, append-only)
Door attempts ingested from vendor webhooks. Append-only; never mutated; part of the audit substrate.
export type KeyCredentialAttemptId = Brand<string, 'KeyCredentialAttemptId'>;
export type AttemptOutcome = 'granted' | 'denied';
export type AttemptDenyReason =
| 'expired' | 'suspended' | 'revoked' | 'wrong_room'
| 'outside_window' | 'unknown_credential' | 'lock_offline' | 'other';
export interface KeyCredentialAttempt {
readonly id: KeyCredentialAttemptId;
readonly tenantId: TenantId;
readonly propertyId: PropertyId;
readonly keyCredentialId?: KeyCredentialId; // null when 'unknown_credential'
readonly deviceId: LockDeviceId;
readonly attemptedAt: ISODate; // vendor-reported time
readonly ingestedAt: ISODate; // server time
readonly outcome: AttemptOutcome;
readonly denyReason?: AttemptDenyReason;
readonly vendor: VendorCode;
readonly vendorEventId: string; // for dedupe; opaque
readonly metadata: Record<string, string>; // small, vendor-normalized; never PII
}
1.5 KeyCredentialAttempt invariants
- A-1. Append-only — no
updatemethod exists; rows are inserted viaKeyCredentialAttemptRepository.append(). - A-2.
(vendor, vendorEventId)is unique within a tenant for idempotent ingestion. - A-3.
attemptedAt <= ingestedAt + clockSkewToleranceMs (5 min)— vendor times in the future are clamped and logged as a warning. - A-4. When
keyCredentialIdreferences a known credential, the credential'stenantId/propertyIdmust match the attempt's; mismatch throwsCrossTenantReferenceError.
2. LockDevice aggregate
export interface LockCapabilities {
readonly mobileKey: boolean;
readonly cardEncoding: boolean;
readonly pin: boolean;
readonly qr: boolean;
readonly nfc: boolean;
readonly remoteRevoke: boolean;
readonly remoteIssue: boolean;
readonly offlineIssuance: boolean;
readonly scopeFloors: boolean;
readonly scopeAreas: boolean;
}
export interface LockDevice {
readonly id: LockDeviceId;
readonly tenantId: TenantId;
readonly propertyId: PropertyId;
readonly vendor: VendorCode;
readonly vendorDeviceRef: string; // opaque vendor identifier
readonly label: string; // "Room 204 main door"
readonly rooms: RoomId[]; // mapped rooms (often 1)
readonly capabilities: LockCapabilities;
readonly online: boolean;
readonly battery?: { percent: number; lowThresholdPercent: number };
readonly firmware?: string;
readonly clockSkewMs?: number;
readonly lastSeenAt: ISODate;
readonly registeredAt: ISODate;
readonly decommissionedAt?: ISODate;
}
2.1 LockDevice invariants
- D-1.
vendorDeviceRefis unique within(tenantId, propertyId, vendor). - D-2.
decommissionedAt !== undefined⇒ device cannot be returned bylistDevices()and any subsequenthealthCheckreturnsonline: false. - D-3.
roomsreferencesRoomIds in the sametenantId/propertyId(cross-tenant reference forbidden). - D-4.
battery.percent∈[0, 100];lowThresholdPercent∈[5, 50]. Crossing the threshold downward emitslock.device.battery_low.v1.
3. VendorAdapter and VendorCredential
export type AdapterEnvironment = 'sandbox' | 'production';
export type CircuitState = 'closed' | 'open' | 'half_open';
export interface AdapterHealthSnapshot {
readonly windowSize: number;
readonly errorRatePct: number;
readonly p95LatencyMs: number;
readonly p99LatencyMs: number;
readonly circuit: CircuitState;
readonly lastTrippedAt?: ISODate;
readonly lastError?: { code: string; vendorMessage?: string; at: ISODate };
}
export interface VendorAdapter {
readonly id: VendorAdapterId;
readonly tenantId: TenantId;
readonly propertyId: PropertyId;
readonly vendor: VendorCode;
readonly environment: AdapterEnvironment;
readonly capabilities: LockCapabilities;
readonly health: AdapterHealthSnapshot;
readonly enabled: boolean;
readonly precedence: number; // lower = preferred when multiple adapters present
readonly createdAt: ISODate;
readonly updatedAt: ISODate;
}
export interface VendorCredential {
readonly id: Brand<string, 'VendorCredentialId'>;
readonly tenantId: TenantId;
readonly propertyId: PropertyId;
readonly vendor: VendorCode;
readonly environment: AdapterEnvironment;
readonly secretResourceName: string; // "projects/<p>/secrets/<n>/versions/latest"
readonly rotatesAt: ISODate; // next scheduled rotation
readonly fingerprint: string; // sha256 of secret bytes for change detection
readonly createdAt: ISODate;
readonly updatedAt: ISODate;
}
3.1 Invariants
- V-1.
(tenantId, propertyId, vendor, environment)is unique onVendorAdapter. - V-2.
VendorCredential.secretResourceNameis a Secret Manager resource path; the value bytes are never persisted in Postgres or any other store. - V-3.
circuit === 'open'⇒ adapter dispatch returnsMELMASTOON.LOCK.VENDOR_UNREACHABLEimmediately without an outbound call until half-open.
4. MasterKey and KeyKindPolicy
export type MasterScope =
| { kind: 'room'; roomId: RoomId }
| { kind: 'floor'; floor: string }
| { kind: 'rooms_assigned' } // computed from open tasks
| { kind: 'rooms_all' }
| { kind: 'areas'; areas: KeyCredentialScope['areas'] }
| { kind: 'areas_all' }
| { kind: 'back_of_house' };
export interface MasterKey {
readonly id: MasterKeyId;
readonly keyCredentialId: KeyCredentialId; // 1:1 with the underlying KeyCredential
readonly tenantId: TenantId;
readonly propertyId: PropertyId;
readonly staffUserId: UserId;
readonly shiftId?: Brand<string, 'ShiftId'>;
readonly scope: MasterScope;
readonly validFrom: ISODate;
readonly validUntil: ISODate;
readonly issuedAt: ISODate;
readonly revokedAt?: ISODate;
}
export interface KeyKindPolicy {
readonly id: Brand<string, 'KeyKindPolicyId'>;
readonly tenantId: TenantId;
readonly propertyId: PropertyId;
readonly preferredOrder: KeyCredentialKind[]; // e.g. ['mobile_app','pin_code','rfid_card']
readonly fallbackChain: KeyCredentialKind[]; // applied on failure of preferred
readonly maxValidUntilExtensionHours: number; // tenant cap
readonly idVerifyRequiredFor: KeyCredentialKind[]; // require ID-doc snapshot before issue
readonly noShowSuspendAfterHours: number; // when reservation.no_show triggers suspend
readonly allowOfflineIssuanceKinds: KeyCredentialKind[]; // subset that may be minted offline
readonly updatedAt: ISODate;
}
4.1 Invariants
- M-1.
MasterKey.keyCredentialIdreferences aKeyCredentialwithholderKind === 'staff_master'. - M-2. A staff user has at most one active
MasterKeyper(propertyId, scope.kind)at a time; overlap on shift renewal is allowed only via explicit re-issue + revoke chain. - K-1.
preferredOrderandfallbackChainare non-empty; every kind appears in at most one of them. - K-2.
maxValidUntilExtensionHoursdefaults to 168 (1 week); cannot exceed 720 (30 days).
5. EncoderSession and OfflineIssuance
export interface EncoderSession {
readonly id: EncoderSessionId;
readonly tenantId: TenantId;
readonly propertyId: PropertyId;
readonly desktopDeviceId: Brand<string, 'DeviceId'>;
readonly encoderModel: string;
readonly transport: 'usb_hid' | 'serial';
readonly openedAt: ISODate;
readonly closedAt?: ISODate;
readonly closeReason?: 'graceful' | 'usb_disconnect' | 'app_quit' | 'error';
}
export interface OfflineIssuance {
readonly id: OfflineIssuanceId;
readonly tenantId: TenantId;
readonly propertyId: PropertyId;
readonly desktopDeviceId: Brand<string, 'DeviceId'>;
readonly serial: string; // included in cert payload
readonly publicKeyEd25519: string; // base64; private key on desktop only
readonly allowedKinds: KeyCredentialKind[];
readonly maxValidWindowHours: number; // hard cap (default 48)
readonly issuedAt: ISODate;
readonly expiresAt: ISODate; // 90d from issuedAt
readonly revokedAt?: ISODate;
readonly revokeReason?: 'rotation' | 'desktop_unbound' | 'tenant_request' | 'compromise';
}
5.1 Invariants
- E-1.
EncoderSession.closedAtis set within 5s of USB disconnect or app quit; the local adapter persists the close reason. - O-1.
OfflineIssuance.expiresAt - issuedAt === 90 days. - O-2. Revocation cascades — all credentials minted under a revoked cert are flagged for forced re-issue at next online check by the saga.
- O-3.
maxValidWindowHours <= 48for all current tenants; future Phase 2 may negotiate longer for low-connectivity properties via ADR.
6. Per-vendor adapter signatures
The LockPort interface (canonical declaration in docs/09 §4) is the only interface consumers depend on. Each per-vendor adapter implements LockPort plus an internal init contract:
export interface VendorAdapterModule<Cfg = unknown> {
readonly vendor: VendorCode;
load(cfg: Cfg, secretFetcher: SecretFetcher, telemetry: AdapterTelemetry): Promise<LockPort>;
describeStatic(): { capabilities: LockCapabilities; latencyTargetsMs: { issueP95: number; revokeP95: number } };
}
// Per-vendor configuration shapes (no secret bytes — always Secret Manager pointers):
export interface TtLockAdapterConfig {
apiBaseUrl: string; // 'https://euapi.ttlock.com'
clientIdSecret: string; // Secret Manager resource name
clientSecretSecret: string;
appOwnerSecret: string; // tenant-onboarding-issued app owner ref
defaultGatewayDeviceId?: string; // optional cloud gateway for remote ops
bleEnabled: boolean;
rateLimitPerMin: number; // default 100
}
export interface SaltoAdapterConfig {
cloudBaseUrl: string; // 'https://api.saltoks.com/xs4'
apiKeySecret: string;
environment: AdapterEnvironment;
onPremConnector?: { url: string; mtlsCertSecret: string; mtlsKeySecret: string };
svnPropagationToleranceSec: number; // default 120
}
export interface VostioAdapterConfig {
cloudBaseUrl: string; // 'https://vostio.assaabloyhospitality.com/api'
oauthTokenUrl: string;
clientIdSecret: string;
clientSecretSecret: string;
environmentRef: string; // tenant-specific Vostio environment id
rateLimitPerSec: number; // default 30
}
export interface GenericWiegandAdapterConfig {
// Cloud-side (CloudProxy) — relays to desktop:
relayWsBaseUrl: string; // wss://lock-relay.melmastoon.ghasi.io
desktopAckTimeoutMs: number; // default 8000
}
export interface DesktopWiegandAdapterConfig {
// Desktop-side (Electron main):
encoderModel: 'hid_omnikey' | 'serial_acr122u' | 'serial_genericiso14443a' | string;
transport: 'usb_hid' | 'serial';
serialPath?: string; // for serial transport
baudRate?: number;
pairingKeyKeychainRef: string; // OS-keychain reference; never on disk plaintext
mifareSectorConfig: { sector: number; keyAType: 'a' | 'b'; keyKeychainRef: string };
}
6.1 Vendor-specific extension hooks
Adapters must implement only LockPort. They may expose internal extension hooks for vendor-specific capabilities the port does not model — but those are consumed by the saga only through capability flags via describeAdapter().capabilities. Examples:
| Capability flag | Vendor with native support | Saga behavior when false |
|---|---|---|
cardEncoding | Salto, Wiegand, some TTLock | Saga removes rfid_card from preferred kinds before dispatch |
offlineIssuance | Wiegand (always), TTLock BLE-direct (when paired), Salto (limited via SVN propagation) | Cloud-only flow; no provisional path |
scopeFloors | Salto, Vostio | Saga rejects floor-scoped masters; falls back to per-room enumeration |
scopeAreas | Vostio, partial Salto | Same as above for areas |
7. Domain events emitted by aggregates
Each transition appends a typed DomainEvent to KeyCredentialAggregate.pendingEvents(). The application layer flushes them through the transactional outbox (04 §5). Event names map 1:1 to topic subjects in EVENT_SCHEMAS:
| Aggregate transition | Domain event | Topic subject |
|---|---|---|
request() | KeyCredentialRequested | melmastoon.lock.credential.requested.v1 |
markActive() | KeyCredentialIssued | melmastoon.lock.credential.issued.v1 |
markFailed() | KeyCredentialFailed | melmastoon.lock.credential.failed.v1 |
update() | KeyCredentialUpdated | melmastoon.lock.credential.updated.v1 |
revoke() | KeyCredentialRevoked | melmastoon.lock.credential.revoked.v1 |
suspend() | KeyCredentialSuspended | melmastoon.lock.credential.suspended.v1 |
unsuspend() | KeyCredentialUnsuspended | melmastoon.lock.credential.unsuspended.v1 |
LockDevice, MasterKey, EncoderSession, VendorAdapter all emit their own events; see EVENT_SCHEMAS for the full catalog.
8. Domain errors
Defined under domain/errors/ and mapped to canonical codes by the presentation layer per ERROR_CODES — LOCK:
| Error class | Code | HTTP |
|---|---|---|
VendorUnreachableError | MELMASTOON.LOCK.VENDOR_UNREACHABLE | 502 |
KeyIssueFailedError | MELMASTOON.LOCK.KEY_ISSUE_FAILED | 502 |
KeyRevokeFailedError | MELMASTOON.LOCK.KEY_REVOKE_FAILED | 502 |
DeviceNotPairedError | MELMASTOON.LOCK.DEVICE_NOT_PAIRED | 409 |
CredentialExpiredError | MELMASTOON.LOCK.CREDENTIAL_EXPIRED | 410 |
CardEncoderOfflineError | MELMASTOON.LOCK.CARD_ENCODER_OFFLINE | 503 |
CrossTenantReferenceError | MELMASTOON.GENERAL.CROSS_TENANT_REFERENCE | 422 |
InvalidStateTransitionError | MELMASTOON.GENERAL.VALIDATION_FAILED | 422 (sub-code invalid_state_transition) |
WebhookSignatureInvalidError | MELMASTOON.LOCK.WEBHOOK_SIGNATURE_INVALID (proposed addition) | 401 |
OfflineCertExpiredError | MELMASTOON.LOCK.CREDENTIAL_EXPIRED (sub-code offline_cert_expired) | 410 |
Adapters translate vendor errors to one of the above; raw vendor error classes never escape the adapter boundary.
9. Builders (test utilities)
Under domain/__builders__/, colocated with the aggregates. Used by unit + integration tests to construct valid aggregates with sensible defaults:
export const aKeyCredential = (over?: Partial<KeyCredential>): KeyCredential => ({
id: 'key_01HX...' as KeyCredentialId,
tenantId: 'tnt_01...' as TenantId,
propertyId: 'ppt_01...' as PropertyId,
holderKind: 'guest',
reservationId: 'rsv_01...' as ReservationId,
guestId: 'gst_01...' as GuestId,
kind: 'mobile_app',
rooms: ['rmu_01...'] as RoomId[],
scope: {},
validFrom: '2026-05-01T14:00:00Z',
validUntil: '2026-05-03T11:00:00Z',
state: 'active',
vendor: 'ttlock',
vendorAdapterId: 'vad_01...' as VendorAdapterId,
vendorRef: '<opaque>',
provisional: false,
idempotencyKey: 'sha256...',
version: 1,
createdAt: '2026-04-30T10:00:00Z',
updatedAt: '2026-04-30T10:00:01Z',
...over,
});
Builders are pure, deterministic by default, and accept overrides for specific test scenarios. They are never imported by production code (CI ESLint boundary check enforces).