maintenance-service · EVENT_SCHEMAS
All events conform to the platform envelope (
docs/04-event-driven-architecture.md§3) and the naming rulemelmastoon.<service>.<aggregate>.<verb-past-tense>.v<n>. Topics map 1:1 to the dotted name. Retention class isoperational(30 days hot, 18 months cold) unless flaggedregulated(7 years cold). Partition key istenantIdunless noted.
1. Common envelope
export interface EventEnvelope<T = unknown> {
readonly id: ULID; // event id; also the Pub/Sub message id we set
readonly type: string; // e.g. "melmastoon.maintenance.work_order.created.v1"
readonly version: 1; // envelope version
readonly tenantId: TenantId;
readonly propertyId?: PropertyId;
readonly correlationId: ULID; // trace correlation
readonly causationId?: ULID; // upstream event id
readonly producedAt: string; // ISO-8601 UTC
readonly producedBy: { service: 'maintenance-service'; build: string; revision: string };
readonly partitionKey: string; // = tenantId by default
readonly retentionClass: 'operational' | 'regulated';
readonly schemaUri: string; // gs:// URI of the JSON Schema
readonly payload: T;
}
2. Topic registry (this service)
| Topic | Class | Partition key | Schema |
|---|---|---|---|
melmastoon.maintenance.work_order.created.v1 | operational | tenantId | WorkOrderCreatedV1 |
melmastoon.maintenance.work_order.assigned.v1 | operational | tenantId | WorkOrderAssignedV1 |
melmastoon.maintenance.work_order.started.v1 | operational | tenantId | WorkOrderStartedV1 |
melmastoon.maintenance.work_order.in_progress.v1 | operational | tenantId | WorkOrderInProgressNotedV1 |
melmastoon.maintenance.work_order.blocked.v1 | operational | tenantId | WorkOrderBlockedV1 |
melmastoon.maintenance.work_order.resolved.v1 | operational | tenantId | WorkOrderResolvedV1 |
melmastoon.maintenance.work_order.verified.v1 | operational | tenantId | WorkOrderVerifiedV1 |
melmastoon.maintenance.work_order.cancelled.v1 | operational | tenantId | WorkOrderCancelledV1 |
melmastoon.maintenance.work_order.escalated.v1 | operational | tenantId | WorkOrderEscalatedV1 |
melmastoon.maintenance.work_order.sla_breached.v1 | operational | tenantId | WorkOrderSlaBreachedV1 |
melmastoon.maintenance.work_order.room_blocked.v1 | operational | tenantId | WorkOrderRoomBlockedV1 |
melmastoon.maintenance.work_order.relocation_required.v1 | operational | tenantId | WorkOrderRelocationRequiredV1 |
melmastoon.maintenance.preventive.scheduled.v1 | operational | tenantId | PreventiveScheduledV1 |
melmastoon.maintenance.preventive.due.v1 | operational | tenantId | PreventiveDueV1 |
melmastoon.maintenance.preventive.completed.v1 | operational | tenantId | PreventiveCompletedV1 |
melmastoon.maintenance.asset.registered.v1 | operational | tenantId | AssetRegisteredV1 |
melmastoon.maintenance.asset.health_changed.v1 | operational | tenantId | AssetHealthChangedV1 |
melmastoon.maintenance.vendor.assigned.v1 | operational | tenantId | VendorAssignedV1 |
melmastoon.maintenance.vendor.invoice_recorded.v1 | regulated | tenantId | VendorInvoiceRecordedV1 |
melmastoon.maintenance.part_usage.recorded.v1 | operational | tenantId | PartUsageRecordedV1 |
3. Payload contracts
3.1 WorkOrderCreatedV1
export interface WorkOrderCreatedV1 {
readonly workOrderId: WorkOrderId;
readonly propertyId: PropertyId;
readonly roomId?: RoomId;
readonly assetId?: AssetId;
readonly category: CategoryCode;
readonly severity: WorkOrderSeverity;
readonly title: string;
readonly source: CreationSource;
readonly originRef?: string;
readonly createdBy: UserId | 'system';
readonly slaTimer?: { targetMinutes: number; dueAt: string };
readonly aiAssist?: { categoryClassified: boolean; severitySuggested: boolean };
}
{
"type": "melmastoon.maintenance.work_order.created.v1",
"version": 1,
"tenantId": "tnt_01HXP...",
"propertyId": "prop_01HXP...",
"correlationId": "01HXYZ...",
"producedAt": "2026-04-22T14:03:21.812Z",
"producedBy": { "service": "maintenance-service", "build": "1.4.0", "revision": "g7f9c12" },
"partitionKey": "tnt_01HXP...",
"retentionClass": "operational",
"schemaUri": "gs://melmastoon-event-schemas/maintenance/work_order_created_v1.json",
"payload": {
"workOrderId": "mnt_01HXY...",
"propertyId": "prop_01HXP...",
"roomId": "room_01HXR...",
"category": "hvac",
"severity": "high",
"title": "AC blowing warm air in 204",
"source": "guest_complaint",
"createdBy": "usr_01HXG...",
"slaTimer": { "targetMinutes": 240, "dueAt": "2026-04-22T18:03:21.812Z" }
}
}
3.2 WorkOrderAssignedV1
export interface WorkOrderAssignedV1 {
readonly workOrderId: WorkOrderId;
readonly assignee: AssigneeRef;
readonly assignedBy: UserId;
readonly previousAssignee?: AssigneeRef;
readonly notifyChannel?: 'whatsapp' | 'sms' | 'email' | 'call_only';
}
3.3 WorkOrderStartedV1
export interface WorkOrderStartedV1 {
readonly workOrderId: WorkOrderId;
readonly startedBy: UserId;
readonly startedAt: string;
}
3.4 WorkOrderInProgressNotedV1
export interface WorkOrderInProgressNotedV1 {
readonly workOrderId: WorkOrderId;
readonly note: string;
readonly notedBy: UserId;
readonly notedAt: string;
}
3.5 WorkOrderBlockedV1
export interface WorkOrderBlockedV1 {
readonly workOrderId: WorkOrderId;
readonly reason: 'awaiting_part' | 'awaiting_vendor' | 'awaiting_approval' | 'guest_in_room' | 'other';
readonly note?: string;
readonly etaIso?: string;
readonly blockedBy: UserId | 'system';
}
3.6 WorkOrderResolvedV1
export interface WorkOrderResolvedV1 {
readonly workOrderId: WorkOrderId;
readonly resolutionNote: string;
readonly costRollup: { currency: string; amountMicro: string }; // bigint serialised
readonly costLines: ReadonlyArray<{
readonly kind: 'labor' | 'part' | 'vendor_invoice' | 'other';
readonly description: string;
readonly amount: { currency: string; amountMicro: string };
readonly minutes?: number;
}>;
readonly partsUsed: ReadonlyArray<{
readonly partId: PartId;
readonly quantity: number;
readonly unitCost: { currency: string; amountMicro: string };
}>;
readonly resolvedBy: UserId;
readonly resolvedAt: string;
readonly preventiveScheduleId?: PreventiveScheduleId;
}
3.7 WorkOrderVerifiedV1
export interface WorkOrderVerifiedV1 {
readonly workOrderId: WorkOrderId;
readonly verifiedBy: UserId; // must be GM or owner
readonly verifiedAt: string;
readonly note?: string;
readonly releasedRoomId?: RoomId; // if causedRoomBlock was true
readonly preventiveScheduleId?: PreventiveScheduleId;
readonly nextDueAt?: string;
}
3.8 WorkOrderCancelledV1
export interface WorkOrderCancelledV1 {
readonly workOrderId: WorkOrderId;
readonly reason: string;
readonly cancelledBy: UserId;
readonly cancelledAt: string;
readonly releasedRoomId?: RoomId;
}
3.9 WorkOrderEscalatedV1
export interface WorkOrderEscalatedV1 {
readonly workOrderId: WorkOrderId;
readonly reason: string;
readonly escalatedBy: UserId | 'system';
readonly fromAssignee?: AssigneeRef;
readonly toTarget: { kind: 'user'; userId: UserId } | { kind: 'role'; role: string };
readonly hopNumber: number; // 1, 2, 3...
}
3.10 WorkOrderSlaBreachedV1
export interface WorkOrderSlaBreachedV1 {
readonly workOrderId: WorkOrderId;
readonly category: CategoryCode;
readonly severity: WorkOrderSeverity;
readonly targetMinutes: number;
readonly elapsedMinutes: number;
readonly breachedAt: string;
readonly breachCount: number;
}
3.11 WorkOrderRoomBlockedV1
This is a request event consumed by
property-serviceto flipRoom.status = out_of_order. We do not write to property's tables.
export interface WorkOrderRoomBlockedV1 {
readonly workOrderId: WorkOrderId;
readonly roomId: RoomId;
readonly reason: 'maintenance_high_severity' | 'maintenance_critical_severity';
readonly estimatedDurationMinutes?: number;
readonly category: CategoryCode;
readonly requestedAt: string;
}
3.12 WorkOrderRelocationRequiredV1
export interface WorkOrderRelocationRequiredV1 {
readonly workOrderId: WorkOrderId;
readonly roomId: RoomId;
readonly affectedReservationIds: readonly ReservationId[];
readonly windowFrom: string;
readonly windowTo: string;
readonly severity: WorkOrderSeverity;
}
3.13 PreventiveScheduledV1
export interface PreventiveScheduledV1 {
readonly scheduleId: PreventiveScheduleId;
readonly assetId?: AssetId;
readonly assetClass?: AssetClass;
readonly category: CategoryCode;
readonly cadence: PreventiveSchedule['cadence'];
readonly active: boolean;
readonly action: 'created' | 'updated' | 'deactivated';
}
3.14 PreventiveDueV1
export interface PreventiveDueV1 {
readonly scheduleId: PreventiveScheduleId;
readonly draftWorkOrderId: WorkOrderId;
readonly dueAt: string;
readonly assetId?: AssetId;
readonly category: CategoryCode;
}
3.15 PreventiveCompletedV1
export interface PreventiveCompletedV1 {
readonly scheduleId: PreventiveScheduleId;
readonly workOrderId: WorkOrderId;
readonly completedAt: string;
readonly nextDueAt: string;
readonly runHoursAtCompletion?: number;
}
3.16 AssetRegisteredV1
export interface AssetRegisteredV1 {
readonly assetId: AssetId;
readonly propertyId: PropertyId;
readonly roomId?: RoomId;
readonly class: AssetClass;
readonly displayName: string;
readonly model?: string;
readonly serialNumber?: string;
readonly externalRef?: string;
}
3.17 AssetHealthChangedV1
export interface AssetHealthChangedV1 {
readonly assetId: AssetId;
readonly previousHealthIndex: number;
readonly newHealthIndex: number;
readonly delta: number;
readonly source: 'manual' | 'ai_forecaster' | 'lock_health_alert' | 'work_order_resolved' | 'sensor';
readonly aiProvenance?: AIProvenance;
}
3.18 VendorAssignedV1
export interface VendorAssignedV1 {
readonly workOrderId: WorkOrderId;
readonly vendorId: VendorId;
readonly displayName: string;
readonly category: CategoryCode;
readonly notifyChannel: 'whatsapp' | 'sms' | 'email' | 'call_only';
}
3.19 VendorInvoiceRecordedV1 (regulated)
export interface VendorInvoiceRecordedV1 {
readonly workOrderId: WorkOrderId;
readonly vendorId: VendorId;
readonly amount: { currency: string; amountMicro: string };
readonly invoiceNumber: string;
readonly issuedAt: string;
readonly dueAt: string;
readonly fileRef?: string;
}
3.20 PartUsageRecordedV1
export interface PartUsageRecordedV1 {
readonly partUsageId: PartUsageId;
readonly partId: PartId;
readonly workOrderId?: WorkOrderId;
readonly quantity: number;
readonly unitCost: { currency: string; amountMicro: string };
readonly totalCost: { currency: string; amountMicro: string };
readonly recordedBy: UserId;
readonly recordedAt: string;
}
4. Consumed events (this service is a subscriber)
| Source topic | Producer | Subscription | Consumer behaviour |
|---|---|---|---|
melmastoon.housekeeping.room.maintenance_required.v1 | housekeeping-service | mnt.in.housekeeping.maintenance_required | Auto-create WO; src=housekeeping_flag |
melmastoon.lock_integration.device.health_alert.v1 | lock-integration-service | mnt.in.lock.health_alert | Upsert Asset; auto-create WO; category=lock |
melmastoon.property.room.taken_out_of_order.v1 | property-service | mnt.in.property.room_taken_ooo | Link to active WOs |
melmastoon.property.room.returned_to_service.v1 | property-service | mnt.in.property.room_released | Sanity check |
melmastoon.staff.shift.started.v1 | staff-service | mnt.in.staff.shift_started | Refresh roster cache |
melmastoon.tenant.settings.changed.v1 | tenant-service | mnt.in.tenant.settings_changed | Refresh SLA / escalation cache |
melmastoon.reservation.checked_in.v1 | reservation-service | mnt.in.reservation.checked_in | Re-evaluate WOs on the room |
melmastoon.billing.vendor_invoice.posted.v1 | billing-service | mnt.in.billing.vendor_invoice_posted | Mark posted_to_folio = true |
5. Schema evolution rules
- Additive only within v1: optional fields may be added; existing fields' types may not change; no field renames.
- Removal: expand-contract with a v2 topic; v1 stays for ≥ 6 months; both published in parallel during deprecation.
- Breaking: v2 topic, v2 schema, dual publish, consumers migrate, v1 frozen for 6 months then archived.
- Schema URI is immutable per topic version. Versioned files live in the
event-schemasGCS bucket.
6. Producer guarantees
- At-least-once delivery via Pub/Sub.
- Outbox-tx parity: an event is appended to
outboxin the same transaction as its state mutation. The outbox relay publishes within < 2 s p99. - Ordering: only within a
partitionKey(tenantId). Cross-tenant ordering is undefined. - Idempotency on consumer: every handler must use
messageIdagainst aninbox_processedtable.
7. Testing
- Every payload type has a
*.contract.test.tsthat asserts:- Sample valid JSON parses with the published schema.
- Sample missing-required fails.
- TypeScript type matches the JSON Schema (via
ts-json-schema-generatorgolden file).
- Cross-service contract tests (Pact) verify expected payloads from
housekeeping-service,lock-integration-service,property-service,reservation-service,billing-service, andstaff-service.