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)
| Topic | Aggregate | Retention | Ordering key | Schema file |
|---|---|---|---|---|
melmastoon.staff.created.v1 | Staff | 7 d | staffId | schemas/events/staff/staff/created/v1.json |
melmastoon.staff.updated.v1 | Staff | 7 d | staffId | …/updated/v1.json |
melmastoon.staff.position_changed.v1 | Staff | 7 d | staffId | …/position_changed/v1.json |
melmastoon.staff.terminated.v1 | Staff | 30 d | staffId | …/terminated/v1.json |
melmastoon.staff.reactivated.v1 | Staff | 7 d | staffId | …/reactivated/v1.json |
melmastoon.staff.shift.scheduled.v1 | Shift | 7 d | shiftId | …/shift/scheduled/v1.json |
melmastoon.staff.shift.assigned.v1 | Shift | 7 d | shiftId | …/shift/assigned/v1.json |
melmastoon.staff.shift.unassigned.v1 | Shift | 7 d | shiftId | …/shift/unassigned/v1.json |
melmastoon.staff.shift.swapped.v1 | Shift | 7 d | shiftId | …/shift/swapped/v1.json |
melmastoon.staff.shift.cancelled.v1 | Shift | 7 d | shiftId | …/shift/cancelled/v1.json |
melmastoon.staff.shift.started.v1 | Shift | 7 d | shiftId | …/shift/started/v1.json |
melmastoon.staff.shift.ended.v1 | Shift | 7 d | shiftId | …/shift/ended/v1.json |
melmastoon.staff.shift.staffing_gap_detected.v1 | Shift | 7 d | shiftId | …/shift/staffing_gap_detected/v1.json |
melmastoon.staff.clock.in.v1 | ClockEntry | 7 d | staffId | …/clock/in/v1.json |
melmastoon.staff.clock.out.v1 | ClockEntry | 7 d | staffId | …/clock/out/v1.json |
melmastoon.staff.clock.break_started.v1 | ClockEntry | 7 d | staffId | …/clock/break_started/v1.json |
melmastoon.staff.clock.break_ended.v1 | ClockEntry | 7 d | staffId | …/clock/break_ended/v1.json |
melmastoon.staff.leave.requested.v1 | LeaveRequest | 7 d | staffId | …/leave/requested/v1.json |
melmastoon.staff.leave.approved.v1 | LeaveRequest | 30 d | staffId | …/leave/approved/v1.json |
melmastoon.staff.leave.rejected.v1 | LeaveRequest | 30 d | staffId | …/leave/rejected/v1.json |
melmastoon.staff.leave.cancelled.v1 | LeaveRequest | 7 d | staffId | …/leave/cancelled/v1.json |
melmastoon.staff.certification.added.v1 | Certification | 7 d | staffId | …/certification/added/v1.json |
melmastoon.staff.certification.expired.v1 | Certification | 30 d | staffId | …/certification/expired/v1.json |
melmastoon.staff.handoff.note_added.v1 | HandoffNote | 7 d | propertyId | …/handoff/note_added/v1.json |
Naming note. Subjects like
staff.clock.in.v1useinas the verb. Per NAMING §6 verbs are past-tense by convention;clock.inandclock.outare 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
| Subject | Producer | Schema URI | Inbox key |
|---|---|---|---|
melmastoon.iam.user.registered.v1 | iam-service | https://schemas.melmastoon.com/iam/user/registered/v1.json | eventId |
melmastoon.tenant.membership.created.v1 | tenant-service | https://schemas.melmastoon.com/tenant/membership/created/v1.json | eventId |
melmastoon.tenant.membership.removed.v1 | tenant-service | https://schemas.melmastoon.com/tenant/membership/removed/v1.json | eventId |
melmastoon.property.deactivated.v1 | property-service | https://schemas.melmastoon.com/property/deactivated/v1.json | eventId |
melmastoon.ai.suggestion.shift_optimization.v1 | ai-orchestrator | https://schemas.melmastoon.com/ai/suggestion/shift_optimization/v1.json | eventId |
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. Bothv1andv2are produced in parallel for the deprecation window (≥ 60 d); subscribers migrate; thenv1is 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)
| Event | Emitting use case |
|---|---|
staff.created.v1 | CreateStaff, OnTenantMembershipCreated (default shell) |
staff.updated.v1 | UpdateStaff, SetClockPin, OnIamUserRegistered (link) |
staff.position_changed.v1 | ChangePosition |
staff.terminated.v1 | TerminateStaff, OnTenantMembershipRemoved |
staff.reactivated.v1 | ReactivateStaff |
staff.shift.scheduled.v1 | GenerateShifts, CreateAdHocShift |
staff.shift.assigned.v1 | AssignStaff, PromoteStandby |
staff.shift.unassigned.v1 | UnassignStaff, DecideLeave (cascade) |
staff.shift.swapped.v1 | SwapAssignments |
staff.shift.cancelled.v1 | CancelShift, OnPropertyDeactivated |
staff.shift.started.v1 | PunchClock (kind=in, first primary) |
staff.shift.ended.v1 | PunchClock (kind=out, last primary), AutoCloseShifts |
staff.shift.staffing_gap_detected.v1 | AutoCloseShifts (gap-warn branch) |
staff.clock.{in,out,break_*}.v1 | PunchClock, ManagerOverridePunch, OfflineReplay |
staff.leave.requested.v1 | SubmitLeave |
staff.leave.approved.v1 | DecideLeave (approve) |
staff.leave.rejected.v1 | DecideLeave (reject) |
staff.leave.cancelled.v1 | CancelLeave |
staff.certification.added.v1 | AddCertification |
staff.certification.expired.v1 | Scheduled cert-expiry-scanner job |
staff.handoff.note_added.v1 | AddHandoffNote |
7. Dead-Letter & Replay
- Each subscription has a DLQ at
<topic>.dlq.v1with TTL 30 d. - DLQ replay is operator-initiated via
bff-backoffice-serviceadmin endpoint and audited. - Replays carry
metadata.fromOfflineReplay = trueif originating from device queue, ormetadata.fromDlqReplay = trueotherwise. Idempotency is the consumer's responsibility — see inbox table.