Skip to main content

housekeeping-service — EVENT_SCHEMAS

All events follow the canonical envelope from docs/04-event-driven-architecture.md. Subjects: melmastoon.<service>.<aggregate>.<verb-past-tense>.v<n>. Payloads JSON, encoded UTF-8, max 256 KiB. Schemas validated against contracts/events/*.schema.json in CI.


1. Common envelope

Every published message wraps the payload below in the standard envelope:

{
"specVersion": "1.0",
"id": "evt_01J0…",
"subject": "melmastoon.housekeeping.task.created.v1",
"tenantId": "tnt_01HZ…",
"occurredAt": "2026-04-22T11:05:11.451Z",
"producer": "housekeeping-service@1.4.2",
"traceparent": "00-…-…-01",
"actor": { "type": "system|user|integration", "id": "stf_… | sys_…" },
"correlationId":"req_01HZ…",
"causationId": "evt_01J0…",
"payload": { /* per-subject payload below */ }
}

Idempotency key for consumers = (subject, id). Retention = 7 days at Pub/Sub; 90 days in event_archive GCS bucket.

2. Published events

2.1 melmastoon.housekeeping.task.created.v1

interface TaskCreatedV1 {
taskId: string; // hkt_…
tenantId: string;
propertyId: string;
roomId: string;
reservationId?: string;
kind: "turnover"|"mid_stay_clean"|"deep_clean"|"post_maintenance"|"post_renovation"|"inspection";
priority: "low"|"normal"|"high"|"urgent";
scheduledFor?: string; // ISO-8601 UTC
checklistId: string;
checklistVersion: number;
localeHint: string; // "ps-AF" | "fa-AF" | …
source: "event"|"manual"|"scheduler";
sourceEventId?: string; // e.g., the reservation.checked_out.v1 event id
}

Example payload:

{ "taskId":"hkt_01J…","tenantId":"tnt_01HZ…","propertyId":"prp_01HZ…",
"roomId":"rom_01HZ…","reservationId":"rsv_01HZ…","kind":"turnover",
"priority":"high","scheduledFor":"2026-04-22T11:30:00Z",
"checklistId":"chl_01HZ…","checklistVersion":7,"localeHint":"ps-AF",
"source":"event","sourceEventId":"evt_01J…" }

2.2 .assigned.v1 / .reassigned.v1

interface TaskAssignedV1 { taskId:string; tenantId:string; staffId:string; }
interface TaskReassignedV1 { taskId:string; tenantId:string; previousStaffId:string;
staffId:string; reason?: "router_suggestion"|"manual"|"sickness"|"capacity"; }

2.3 .started.v1 / .paused.v1 / .resumed.v1

interface TaskStartedV1 { taskId:string; tenantId:string; staffId:string; startedAt:string; }
interface TaskPausedV1 { taskId:string; tenantId:string; staffId:string; pausedAt:string;
reason:"break"|"awaiting_linen"|"awaiting_maintenance"|"staff_swap"|"guest_present"|"other";
note?:string; }
interface TaskResumedV1 { taskId:string; tenantId:string; staffId:string; resumedAt:string; }

2.4 .completed.v1

interface TaskCompletedV1 {
taskId:string; tenantId:string; staffId:string;
completedAt:string; durationMinutes:number;
checklistResults: Array<{ itemKey:string; checked:boolean; note?:string; photoMediaId?:string }>;
linen?: { issued:number; returned:number };
noMaintenanceFound:boolean;
}

2.5 .failed.v1 / .cancelled.v1 / .escalated.v1

interface TaskFailedV1 { taskId:string; tenantId:string; staffId?:string;
reason:"room_not_vacated"|"access_denied"|"sickness"|"equipment_missing"|"other"; note?:string; }
interface TaskCancelledV1 { taskId:string; tenantId:string; reason:string; cancelledBy:{type:string;id:string}; }
interface TaskEscalatedV1 { taskId:string; tenantId:string; to:{ staffId?:string; role?:"supervisor"|"property_manager" };
reason:string; escalatedBy:{type:string;id:string}; }

2.6 melmastoon.housekeeping.room.status_changed.v1

interface RoomStatusChangedV1 {
tenantId:string; propertyId:string; roomId:string;
previousStatus:"clean"|"dirty"|"cleaning"|"cleaned"|"inspected"|"ready"|"out_of_order"|"out_of_service";
status: "clean"|"dirty"|"cleaning"|"cleaned"|"inspected"|"ready"|"out_of_order"|"out_of_service";
flippedAt:string; cause:"task_started"|"task_completed"|"inspection_passed"|"inspection_failed"|
"maintenance_required"|"maintenance_completed"|"manual_override"|"reservation_checked_out";
taskId?:string; actor:{type:string;id:string};
}

Example:

{ "tenantId":"tnt_01HZ…","propertyId":"prp_01HZ…","roomId":"rom_01HZ…",
"previousStatus":"cleaned","status":"ready","flippedAt":"2026-04-22T12:30:11Z",
"cause":"inspection_passed","taskId":"hkt_01J…",
"actor":{"type":"user","id":"stf_01HZ…"} }

2.7 melmastoon.housekeeping.room.maintenance_required.v1

interface RoomMaintenanceRequiredV1 {
tenantId:string; propertyId:string; roomId:string;
taskId:string;
issue: { category:string; severity:"info"|"minor"|"blocking"; description:string; photoMediaIds?:string[] };
reportedAt:string; reportedBy:{ type:"user"; staffId:string };
outcome:"convert_terminal"|"pause";
}

Consumed by maintenance-service to open a work order.

2.8 melmastoon.housekeeping.inspection.passed.v1 / .failed.v1

interface InspectionPassedV1 { inspectionId:string; tenantId:string; taskId:string; roomId:string;
inspectorStaffId:string; performedAt:string; checklistId:string; checklistVersion:number; }
interface InspectionFailedV1 extends InspectionPassedV1 { reason:string; failedItemKeys:string[]; }

2.9 melmastoon.housekeeping.checklist.template_updated.v1

interface ChecklistTemplateUpdatedV1 {
checklistId:string; tenantId:string;
kind:"turnover"|"mid_stay_clean"|"deep_clean"|"post_maintenance"|"post_renovation"|"inspection";
version:number; previousVersion:number;
publishedBy:{type:string;id:string}; publishedAt:string;
diff:{ added:string[]; removed:string[]; modified:string[] }; // item keys
}

2.10 melmastoon.housekeeping.lost_item.recorded.v1 / .matched.v1 / .disposed.v1

interface LostItemRecordedV1 { lostItemId:string; tenantId:string; propertyId:string; roomId:string;
reservationId?:string; description:string; photoMediaIds?:string[]; recordedAt:string; finderStaffId:string; }
interface LostItemMatchedV1 { lostItemId:string; tenantId:string;
claimantName:string; claimantPhone?:string; matchedAt:string; matchedByStaffId:string; }
interface LostItemDisposedV1 { lostItemId:string; tenantId:string; method:"donated"|"trashed"|"kept_storage";
disposedAt:string; note?:string; }

2.11 melmastoon.housekeeping.linen.low_stock_alert.v1

interface LinenLowStockAlertV1 { tenantId:string; propertyId:string;
line:string; onHand:number; lowWatermark:number; detectedAt:string; }

Debounced: at most once per (tenant, property, line) per 60 minutes.

2.12 melmastoon.housekeeping.shift.staffing_gap_detected.v1

interface ShiftStaffingGapDetectedV1 { tenantId:string; propertyId:string; shiftId:string;
detectedAt:string; pendingMinutes:number; capacityMinutes:number;
suggestedAction:"call_relief"|"reroute"|"defer_low_priority";
affectedTaskIds:string[]; }

3. Consumed events

SubjectProducerEffectIdempotency
melmastoon.reservation.checked_out.v1reservation-serviceCreateTaskUseCase(kind=Turnover) + RoomStatus → dirty(subject, id) inbox
melmastoon.reservation.early_checkout.v1reservation-serviceCreateTaskUseCase if absent; BumpPriorityUseCaseinbox
melmastoon.reservation.modification.requested.v1reservation-serviceIf mods includes mid_stay_cleanCreateTaskUseCase(kind=MidStayClean)inbox
melmastoon.reservation.cancelled.v1reservation-serviceCancel pending tasks for the reservationinbox
melmastoon.maintenance.work_order.completed.v1maintenance-serviceUnblock room; CreateTaskUseCase(kind=PostMaintenance)inbox
melmastoon.staff.shift.started.v1staff-serviceProject StaffShiftAssignmentinbox
melmastoon.staff.shift.ended.v1staff-serviceTear down assignment; reroute open tasksinbox
melmastoon.ai_orchestrator.suggestion.housekeeping_routing.v1ai-orchestrator-serviceApply via HITL gateinbox
melmastoon.property.room.archived.v1property-serviceCancel pending tasks; clear RoomStatus rowinbox
melmastoon.tenant.settings.changed.v1tenant-serviceRefresh in-memory tenant cachenot strict (cache reload)

3.1 Expected consumed shape (subset)

interface ReservationCheckedOutV1 {
reservationId:string; tenantId:string; propertyId:string;
checkedOutAt:string; earlyCheckout:boolean; overstayedNights:number;
rooms: Array<{ itemId:string; roomId:string }>;
actor:{type:string;id:string};
}
interface RoutingSuggestionV1 {
suggestionId:string; tenantId:string; propertyId:string; shiftId:string;
rows: Array<{ taskId:string; staffId:string; score:number; reasoning:string }>;
generatedAt:string; modelVersion:string;
}

4. Topic / subscription topology (GCP Pub/Sub)

TopicModeSubscribers (this service)
melmastoon.housekeeping.task.*publishexternal: notification-service, analytics-service, audit-service, search-aggregation-service
melmastoon.housekeeping.room.*publishexternal: search-aggregation-service, analytics-service, audit-service, bff-backoffice-service (board)
melmastoon.housekeeping.room.maintenance_required.v1publishmaintenance-service (push), notification-service
melmastoon.housekeeping.linen.low_stock_alert.v1publishnotification-service
melmastoon.housekeeping.shift.staffing_gap_detected.v1publishnotification-service, bff-backoffice-service
melmastoon.reservation.checked_out.v1consumepush → /internal/events/reservation.checked-out
melmastoon.reservation.early_checkout.v1consumepush
melmastoon.reservation.modification.requested.v1consumepush
melmastoon.maintenance.work_order.completed.v1consumepush
melmastoon.staff.shift.*consumepush
melmastoon.ai_orchestrator.suggestion.housekeeping_routing.v1consumepush
melmastoon.property.room.archived.v1consumepush
melmastoon.tenant.settings.changed.v1consumepush

Push subscriptions deliver with OIDC auth (service-account housekeeping-events@<project>.iam.gserviceaccount.com); ACK deadline 60 s; max deliveries 10; retry policy exponential 10s → 600s; DLQ topic melmastoon.dlq.housekeeping.

5. Schema evolution

  • Additive only within vN. Removing or renaming a field requires vN+1.
  • Both versions run in parallel for ≥ 30 days. Producers may emit only vN+1 once 95% of consumers have migrated.
  • Schema files live in contracts/events/<subject>.schema.json. CI rejects breaking changes against the previous tag.

6. Outbox table (DDL excerpt — full in DATA_MODEL)

CREATE TABLE housekeeping.outbox (
id BIGSERIAL PRIMARY KEY,
event_id TEXT NOT NULL UNIQUE, -- evt_<ulid>
tenant_id TEXT NOT NULL,
subject TEXT NOT NULL,
payload JSONB NOT NULL,
envelope_meta JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
published_at TIMESTAMPTZ
);
CREATE INDEX outbox_unpublished_idx ON housekeeping.outbox (id) WHERE published_at IS NULL;

The outbox relay (sidecar) reads WHERE published_at IS NULL ORDER BY id LIMIT 100, publishes each, then UPDATE … SET published_at = now() on success.