Skip to main content

EVENT_SCHEMAS — staff-service

Sibling: APPLICATION_LOGIC · DATA_MODEL · API_CONTRACTS

Strategic anchors: 04 Event-Driven Architecture · standards/NAMING §6 Events

All events emitted and consumed by staff-service flow through Pub/Sub topics named melmastoon.staff.*.v<n> (produced) or melmastoon.<service>.*.v<n> (consumed). Every event is wrapped in the canonical EventEnvelope<T> (per 04 §4); only the payload shape varies. Schemas live in schemas/events/staff/*.json and are validated in CI via pnpm -F staff-service events:validate.

This document documents payloads; the envelope is identical across the platform.


1. Common Envelope (reference)

export interface EventEnvelope<T> {
eventId: string; // ULID
eventType: string; // e.g. "melmastoon.staff.shift.assigned.v1"
eventVersion: number; // 1
schemaUri: string; // "https://schemas.melmastoon.com/staff/shift/assigned/v1.json"
tenantId: string; // ten_…
correlationId: string;
causationId?: string;
actorId?: string; // usr_… (or 'system_auto')
occurredAt: string; // RFC 3339 UTC
producedBy: 'staff-service';
idempotencyKey?: string;
payload: T;
metadata: {
retentionClass: 'standard' | 'audit' | 'pii';
dataResidency?: string;
fromOfflineReplay?: boolean;
aiProvenance?: { source: 'cloud' | 'edge'; model: string; promptHash: string };
orderingKey?: string; // = aggregateId for ordered partitions
outboxId?: string;
};
}

Pub/Sub orderingKey is set to the aggregate ID for Shift.*, Staff.*, and per-staff for Clock.* (so per-staff order is preserved).


2. Topic Catalog (produced)

TopicAggregateRetentionOrdering keySchema file
melmastoon.staff.created.v1Staff7 dstaffIdschemas/events/staff/staff/created/v1.json
melmastoon.staff.updated.v1Staff7 dstaffId…/updated/v1.json
melmastoon.staff.position_changed.v1Staff7 dstaffId…/position_changed/v1.json
melmastoon.staff.terminated.v1Staff30 dstaffId…/terminated/v1.json
melmastoon.staff.reactivated.v1Staff7 dstaffId…/reactivated/v1.json
melmastoon.staff.shift.scheduled.v1Shift7 dshiftId…/shift/scheduled/v1.json
melmastoon.staff.shift.assigned.v1Shift7 dshiftId…/shift/assigned/v1.json
melmastoon.staff.shift.unassigned.v1Shift7 dshiftId…/shift/unassigned/v1.json
melmastoon.staff.shift.swapped.v1Shift7 dshiftId…/shift/swapped/v1.json
melmastoon.staff.shift.cancelled.v1Shift7 dshiftId…/shift/cancelled/v1.json
melmastoon.staff.shift.started.v1Shift7 dshiftId…/shift/started/v1.json
melmastoon.staff.shift.ended.v1Shift7 dshiftId…/shift/ended/v1.json
melmastoon.staff.shift.staffing_gap_detected.v1Shift7 dshiftId…/shift/staffing_gap_detected/v1.json
melmastoon.staff.clock.in.v1ClockEntry7 dstaffId…/clock/in/v1.json
melmastoon.staff.clock.out.v1ClockEntry7 dstaffId…/clock/out/v1.json
melmastoon.staff.clock.break_started.v1ClockEntry7 dstaffId…/clock/break_started/v1.json
melmastoon.staff.clock.break_ended.v1ClockEntry7 dstaffId…/clock/break_ended/v1.json
melmastoon.staff.leave.requested.v1LeaveRequest7 dstaffId…/leave/requested/v1.json
melmastoon.staff.leave.approved.v1LeaveRequest30 dstaffId…/leave/approved/v1.json
melmastoon.staff.leave.rejected.v1LeaveRequest30 dstaffId…/leave/rejected/v1.json
melmastoon.staff.leave.cancelled.v1LeaveRequest7 dstaffId…/leave/cancelled/v1.json
melmastoon.staff.certification.added.v1Certification7 dstaffId…/certification/added/v1.json
melmastoon.staff.certification.expired.v1Certification30 dstaffId…/certification/expired/v1.json
melmastoon.staff.handoff.note_added.v1HandoffNote7 dpropertyId…/handoff/note_added/v1.json

Naming note. Subjects like staff.clock.in.v1 use in as the verb. Per NAMING §6 verbs are past-tense by convention; clock.in and clock.out are an explicit, documented exception because the term is the HR domain noun-verb ("punch in"/"punch out") and changing it would break operator vocabulary. Future event subjects must use past tense.


3. Selected Payloads

3.1 melmastoon.staff.created.v1

{
"staffId": "stf_01HZA2B3C4D5E6F7G8H9J0K1L2",
"tenantId": "ten_01HZ…",
"homePropertyId": "ppt_01HZ…",
"propertyAccess": ["ppt_01HZ…"],
"staffCode": "GM-DOH-FD-014",
"userId": null,
"givenName": "Bilal",
"familyName": "Khan",
"positionId": "pos_01HZ…FRONT_DESK",
"departmentId": "dpt_01HZ…",
"employmentType": "full_time",
"employmentStatus": "pending_invite",
"employmentStartedAt": "2026-04-15",
"hasEmail": true,
"pinSet": true,
"version": 1,
"createdAt": "2026-04-22T15:33:18.412Z"
}

PII (email, phoneE164, emergencyContact) is never in the event. Subscribers requiring these resolve via the Staff REST API under their own RBAC.

3.2 melmastoon.staff.terminated.v1

{
"staffId": "stf_01HZ…",
"tenantId": "ten_01HZ…",
"userId": "usr_01HZ…",
"homePropertyId": "ppt_01HZ…",
"previousStatus": "active",
"effectiveAt": "2026-05-01",
"reason": "voluntary_resignation",
"cascadeSummary": {
"openClockClosed": true,
"futureAssignmentsUnassigned": 7,
"futureShiftsCancelled": 1,
"iamSessionRevocationRequested": true
},
"version": 5,
"occurredAt": "2026-04-22T15:34:01.000Z"
}

3.3 melmastoon.staff.shift.scheduled.v1

{
"shiftId": "shf_01HZ…",
"tenantId": "ten_01HZ…",
"propertyId": "ppt_01HZ…",
"positionId": "pos_01HZ…HOUSEKEEPER",
"patternId": "shp_01HZ…",
"windowUtc": { "startUtc": "2026-04-23T01:30Z", "endUtc": "2026-04-23T09:30Z" },
"localWindow": { "date": "2026-04-23", "startLocal": "06:00", "endLocal": "14:00", "tz": "Asia/Kabul" },
"primaryHeadcount": 4,
"standbyHeadcount": 1,
"version": 1
}

3.4 melmastoon.staff.shift.assigned.v1

{
"shiftId": "shf_01HZ…",
"tenantId": "ten_01HZ…",
"propertyId": "ppt_01HZ…",
"assignmentId": "sha_01HZ…",
"staffId": "stf_01HZ…",
"role": "primary",
"source": "manual",
"swappedFromAssignmentId": null,
"version": 1,
"assignedAt": "2026-04-22T15:42:11.000Z"
}

3.5 melmastoon.staff.shift.swapped.v1

{
"shiftLeftId": "shf_01HZ…A",
"shiftRightId": "shf_01HZ…B",
"leftAssignment": { "newId": "sha_01HZ…L2", "previousId": "sha_01HZ…L1", "staffId": "stf_01HZ…X" },
"rightAssignment": { "newId": "sha_01HZ…R2", "previousId": "sha_01HZ…R1", "staffId": "stf_01HZ…Y" },
"tenantId": "ten_01HZ…",
"swappedAt": "2026-04-22T15:43:00.000Z"
}

3.6 melmastoon.staff.shift.started.v1

{
"shiftId": "shf_01HZ…",
"tenantId": "ten_01HZ…",
"propertyId": "ppt_01HZ…",
"positionId": "pos_01HZ…HOUSEKEEPER",
"firstClockInBy": "stf_01HZ…",
"firstClockInAt": "2026-04-23T05:58:11Z",
"primaryHeadcount": 4,
"primaryClockedInCount": 1,
"version": 2
}

3.7 melmastoon.staff.shift.ended.v1

{
"shiftId": "shf_01HZ…",
"tenantId": "ten_01HZ…",
"propertyId": "ppt_01HZ…",
"endedAt": "2026-04-23T14:11:02Z",
"endedReason": "all_primary_clocked_out",
"lastClockOutBy": "stf_01HZ…",
"totalActualMinutes": 481,
"totalBreakMinutes": 60,
"version": 4
}

endedReason is one of all_primary_clocked_out | auto_close_grace_exceeded | cancelled_in_progress_force_close.

3.8 melmastoon.staff.shift.staffing_gap_detected.v1

{
"shiftId": "shf_01HZ…",
"tenantId": "ten_01HZ…",
"propertyId": "ppt_01HZ…",
"positionId": "pos_01HZ…FRONT_DESK",
"windowStartsInSeconds": 600,
"headcountRequired": 1,
"headcountClockedIn": 0,
"headcountStandbyAvailable": 1,
"suggestion": "promote_standby",
"detectedAt": "2026-04-23T05:50:00Z"
}

3.9 melmastoon.staff.clock.in.v1

{
"clockEntryId": "clk_01HZ…",
"tenantId": "ten_01HZ…",
"staffId": "stf_01HZ…",
"propertyId": "ppt_01HZ…",
"shiftId": "shf_01HZ…",
"occurredAtUtc": "2026-04-23T05:58:11Z",
"recordedAtUtc": "2026-04-23T05:58:11Z",
"source": "electron_pin",
"deviceId": "dev_01HZ…",
"managerOverride": false,
"fromOfflineReplay": false,
"matchedScheduledShift": true
}

clock.out.v1, clock.break_started.v1, clock.break_ended.v1 share the same envelope shape, with kind implicit in the topic name.

3.10 melmastoon.staff.leave.requested.v1

{
"leaveRequestId": "lvr_01HZ…",
"tenantId": "ten_01HZ…",
"staffId": "stf_01HZ…",
"type": "sick",
"windowLocal": { "from": "2026-04-23", "to": "2026-04-25" },
"reason": "Fever",
"requestedAt": "2026-04-22T19:11:09Z",
"version": 1
}

3.11 melmastoon.staff.leave.approved.v1

{
"leaveRequestId": "lvr_01HZ…",
"tenantId": "ten_01HZ…",
"staffId": "stf_01HZ…",
"decidedBy": "usr_01HZ…",
"decidedAt": "2026-04-22T19:34:51Z",
"forceUnassignedAssignmentIds": ["sha_01HZ…", "sha_01HZ…"],
"decisionNote": "Approved; standby promoted",
"version": 2
}

3.12 melmastoon.staff.certification.expired.v1

{
"certificationId": "crt_01HZ…",
"tenantId": "ten_01HZ…",
"staffId": "stf_01HZ…",
"type": "food_handling",
"issuedAt": "2025-04-23",
"expiredAt": "2026-04-23",
"detectedAt": "2026-04-23T00:00:01Z"
}

3.13 melmastoon.staff.handoff.note_added.v1

{
"handoffNoteId": "hno_01HZ…",
"tenantId": "ten_01HZ…",
"propertyId": "ppt_01HZ…",
"positionId": "pos_01HZ…",
"fromStaffId": "stf_01HZ…",
"fromShiftId": "shf_01HZ…",
"bodyExcerpt": "Room 304 AC noisy; reported to maintenance — ticket mt_01HZ…",
"attachmentCount": 0,
"createdAt": "2026-04-23T14:02:11Z"
}

bodyExcerpt is the first 240 chars; full body retrieved via REST.


4. Consumed Subjects

SubjectProducerSchema URIInbox key
melmastoon.iam.user.registered.v1iam-servicehttps://schemas.melmastoon.com/iam/user/registered/v1.jsoneventId
melmastoon.tenant.membership.created.v1tenant-servicehttps://schemas.melmastoon.com/tenant/membership/created/v1.jsoneventId
melmastoon.tenant.membership.removed.v1tenant-servicehttps://schemas.melmastoon.com/tenant/membership/removed/v1.jsoneventId
melmastoon.property.deactivated.v1property-servicehttps://schemas.melmastoon.com/property/deactivated/v1.jsoneventId
melmastoon.ai.suggestion.shift_optimization.v1ai-orchestratorhttps://schemas.melmastoon.com/ai/suggestion/shift_optimization/v1.jsoneventId

Inbox dedupe table is staff.inbox_processed (event_id, processed_at) with primary key event_id. Reprocessing on replay is detected by inserting; on duplicate-key, the handler returns success without side effects (per 04 §10).


5. Schema Evolution Rules

  • Additive optional fields: minor evolution within the same v1, OK if every consumer's contract test still passes.
  • Required field additions, type changes, semantic changes → bump to v2. Both v1 and v2 are produced in parallel for the deprecation window (≥ 60 d); subscribers migrate; then v1 is retired via DOD checklist.
  • Removing a field is a breaking change. Always bump version.
  • Renaming a field is forbidden. Add the new field, deprecate the old, retire on next major.
  • All consumed events are validated against the published schema on receipt; mismatches go to DLQ with MELMASTOON.STAFF.EVENT_SCHEMA_MISMATCH.

6. Event-to-Use-Case Mapping (produced → emitter)

EventEmitting use case
staff.created.v1CreateStaff, OnTenantMembershipCreated (default shell)
staff.updated.v1UpdateStaff, SetClockPin, OnIamUserRegistered (link)
staff.position_changed.v1ChangePosition
staff.terminated.v1TerminateStaff, OnTenantMembershipRemoved
staff.reactivated.v1ReactivateStaff
staff.shift.scheduled.v1GenerateShifts, CreateAdHocShift
staff.shift.assigned.v1AssignStaff, PromoteStandby
staff.shift.unassigned.v1UnassignStaff, DecideLeave (cascade)
staff.shift.swapped.v1SwapAssignments
staff.shift.cancelled.v1CancelShift, OnPropertyDeactivated
staff.shift.started.v1PunchClock (kind=in, first primary)
staff.shift.ended.v1PunchClock (kind=out, last primary), AutoCloseShifts
staff.shift.staffing_gap_detected.v1AutoCloseShifts (gap-warn branch)
staff.clock.{in,out,break_*}.v1PunchClock, ManagerOverridePunch, OfflineReplay
staff.leave.requested.v1SubmitLeave
staff.leave.approved.v1DecideLeave (approve)
staff.leave.rejected.v1DecideLeave (reject)
staff.leave.cancelled.v1CancelLeave
staff.certification.added.v1AddCertification
staff.certification.expired.v1Scheduled cert-expiry-scanner job
staff.handoff.note_added.v1AddHandoffNote

7. Dead-Letter & Replay

  • Each subscription has a DLQ at <topic>.dlq.v1 with TTL 30 d.
  • DLQ replay is operator-initiated via bff-backoffice-service admin endpoint and audited.
  • Replays carry metadata.fromOfflineReplay = true if originating from device queue, or metadata.fromDlqReplay = true otherwise. Idempotency is the consumer's responsibility — see inbox table.