Cross-Context Event Contracts: Operations (ServiceLeg Lifecycle) β
Formal event contracts for ServiceLeg lifecycle events. For the state machine definition, see schema-operations.md Β§ServiceLeg State Machine. For the broader domain events catalog, see event-catalog.md.
Event Catalog β
| Event | Emitter | Consumer | Purpose |
|---|---|---|---|
ServiceLegStarted | Operations (ServiceLeg β ACTIVE) | Communications (trip-in-progress notifications), Backoffice (dispatch board live status) | Signals driver has started executing the leg |
ServiceLegCompleted | Operations (ServiceLeg β COMPLETED) | Commerce (triggers BookingNoShow evaluation window for final leg), Communications (trip-completed notifications), Backoffice (dispatch board) | Signals leg execution finished |
ServiceLegDelayed | Operations (ServiceLeg β DELAYED) | Backoffice (dispatch board alert overlay) | Signals ETA deviation exceeds delay_threshold or driver-reported delay. Does not trigger passenger broadcasts β passenger notifications flow exclusively through IncidentCreated (see Β§Incident Lifecycle Events). On transition, the system auto-creates a DELAY Incident if none exists (see note below). |
ServiceLegDelayResolved | Operations (ServiceLeg DELAYED β ACTIVE) | Backoffice (dispatch board) | Signals ETA has recovered below recovery_threshold and sustained for delay_dwell_minutes (hysteresis β see schema-operations.md). Auto-resolves any linked system-created DELAY Incident β IncidentResolved fires β Communications sends all-clear. |
ServiceLegCancelled | Operations (ServiceLeg β CANCELLED) | Commerce (seat reservation release), Communications (passenger notification at affected stops), Backoffice (dispatch board) | Signals leg cancellation β always creates a linked Incident |
Payload Schemas β
ServiceLegStarted β
| Field | Type | Description |
|---|---|---|
event_id | UUID | Idempotency key |
tenant_id | UUID | Owning operator |
service_leg_id | UUID | The started leg |
tour_departure_id | UUID | Parent departure |
tour_offering_id | UUID | Parent offering (for Commerce cross-reference) |
leg_type | String | PICKUP, TRANSIT, TRANSFER, DROPOFF, REPOSITIONING |
driver_crew_member_id | UUID | The crew member who started the leg |
actual_start | TIMESTAMPTZ | Actual start timestamp |
ServiceLegCompleted β
| Field | Type | Description |
|---|---|---|
event_id | UUID | Idempotency key |
tenant_id | UUID | Owning operator |
service_leg_id | UUID | The completed leg |
tour_departure_id | UUID | Parent departure |
tour_offering_id | UUID | Parent offering |
leg_type | String | Leg type |
actual_start | TIMESTAMPTZ | When the leg actually started |
actual_end | TIMESTAMPTZ | When the leg actually ended |
is_final_leg | Boolean | Whether this is the last leg in the departure (triggers NoShow evaluation) |
boarding_count | INT | Number of successful boardings (SUCCESS + MANUAL_OVERRIDE) |
ServiceLegDelayed β
| Field | Type | Description |
|---|---|---|
event_id | UUID | Idempotency key |
tenant_id | UUID | Owning operator |
service_leg_id | UUID | The delayed leg |
tour_departure_id | UUID | Parent departure |
tour_offering_id | UUID | Parent offering |
scheduled_end | TIMESTAMPTZ | Original scheduled end |
recalculated_eta | TIMESTAMPTZ | New estimated arrival |
delay_minutes | INT | Deviation in minutes (recalculated_eta - scheduled_end) |
delay_source | String | AUTOMATIC (ETA recalculation) or DRIVER_REPORTED (1-Tap Incident) |
ServiceLegDelayResolved β
| Field | Type | Description |
|---|---|---|
event_id | UUID | Idempotency key |
tenant_id | UUID | Owning operator |
service_leg_id | UUID | The recovered leg |
tour_departure_id | UUID | Parent departure |
recalculated_eta | TIMESTAMPTZ | Updated ETA (now within threshold) |
resolved_at | TIMESTAMPTZ | When the delay resolved |
ServiceLegCancelled β
| Field | Type | Description |
|---|---|---|
event_id | UUID | Idempotency key |
tenant_id | UUID | Owning operator |
service_leg_id | UUID | The cancelled leg |
tour_departure_id | UUID | Parent departure |
tour_offering_id | UUID | Parent offering |
leg_type | String | Leg type (determines cancellation resolution strategy) |
cancelled_by | String | DISPATCHER |
incident_id | UUID | The linked Incident auto-created on cancellation |
had_boarded_passengers | Boolean | Whether any BoardingEvents with SUCCESS/MANUAL_OVERRIDE exist (determines passenger impact) |
cancelled_at | TIMESTAMPTZ | Cancellation timestamp |
Delivery Contracts β
| Event | Mechanism | Trigger | Idempotency |
|---|---|---|---|
ServiceLegStarted | Hasura Event Trigger (async, post-commit) | operations.service_legs.status β ACTIVE | event_id |
ServiceLegCompleted | Hasura Event Trigger (async, post-commit) | operations.service_legs.status β COMPLETED | event_id |
ServiceLegDelayed | Hasura Event Trigger (async, post-commit) | operations.service_legs.status β DELAYED | event_id |
ServiceLegDelayResolved | Hasura Event Trigger (async, post-commit) | operations.service_legs.status DELAYED β ACTIVE | event_id |
ServiceLegCancelled | Hasura Event Trigger (async, post-commit) | operations.service_legs.status β CANCELLED | event_id |
All consumers implement at-least-once processing with event_id-based deduplication. See domain-driven-design.md Β§4 for the standard delivery pattern.
NOTE
ServiceLegDelayResolved trigger (hysteresis): This event is not fired by a simple Hasura Event Trigger on status change. Instead, the ETA recalculation service maintains a debounce timer. When the ETA drops below recovery_threshold (default: 5 min, asymmetric from the 15 min delay_threshold), the service starts a delay_dwell_minutes countdown (default: 3 min). Only if the ETA remains below the recovery threshold for the full dwell period does the service update status β ACTIVE, which then fires the Hasura Event Trigger β NestJS consumer emits ServiceLegDelayResolved. If the ETA rises above recovery_threshold during the dwell period, the countdown resets. This prevents notification flapping when the bus oscillates around the threshold boundary. See schema-operations.md Β§Delay hysteresis for the full parameter table.
IMPORTANT
Auto-incident creation on ServiceLegDelayed: The NestJS webhook handler for the ServiceLegDelayed Event Trigger checks whether a DELAY Incident already exists for the same service_leg_id (dedup: (service_leg_id, type='DELAY', occurred_at Β± 5min)). If no driver-reported Incident exists, the handler auto-creates a system Incident: type=DELAY, severity=CRITICAL, reporter_crew_id=NULL, description='Automatic delay detection', occurred_at=now(). This INSERT fires the IncidentCreated Event Trigger, which enters the unified broadcast chain. If a driver-reported DELAY Incident already exists (driver reported via 1-Tap before the telemetry threshold triggered), the handler skips creation β the driver's Incident already fired IncidentCreated. This ensures every delay has exactly one Incident record and one broadcast evaluation, regardless of detection source.
Auto-resolution on ServiceLegDelayResolved: The NestJS webhook handler for the ServiceLegDelayResolved Event Trigger auto-resolves the linked DELAY Incident: status β RESOLVED, resolution_notes='ETA recovered below threshold', resolved_at=now(). This fires IncidentResolved β Communications sends the all-clear to passengers who received the initial delay broadcast. Driver-reported DELAY Incidents with severity=CRITICAL that were explicitly managed by a dispatcher (status β OPEN) are not auto-resolved β the dispatcher retains ownership.
Incident Lifecycle Events β
For the Incident state machine definition, see schema-operations.md Β§Incident State Machine. For the full broadcast chain protocol, see incident-broadcast-protocol.md.
Event Catalog β
| Event | Emitter | Consumer | Purpose |
|---|---|---|---|
IncidentCreated | Operations (incidents INSERT) | Communications (broadcast for CRITICAL, all types), Backoffice (dispatch board alert via Hasura subscription) | Signals operational disruption (driver-reported or system-created for telemetry delays) |
IncidentResolved | Operations (incidents.status β RESOLVED) | Communications (all-clear for passengers who received broadcast) | Signals incident resolution |
NOTE
Unified broadcast path: IncidentCreated is the sole trigger for all passenger broadcasts β DELAY, BREAKDOWN, and PASSENGER_ISSUE. The ServiceLegDelayed event is a pure state-change event (dispatch board + auto-incident creation only; see Β§ServiceLegDelayed note above). For telemetry-detected delays, the ServiceLegDelayed handler auto-creates a DELAY Incident, which fires IncidentCreated β Communications. For driver-reported delays, the driver's Incident fires IncidentCreated directly. This eliminates the previous duplicate broadcast path and conditional consumer routing.
IncidentCreated β
| Field | Type | Description |
|---|---|---|
event_id | UUID | Idempotency key |
tenant_id | UUID | Owning operator |
incident_id | UUID | The created incident |
service_leg_id | UUID | Impacted leg |
tour_offering_id | UUID | Resolved from service_legs.tour_offering_id β enables passenger query without cross-schema join |
tour_departure_id | UUID | Resolved from service_legs.tour_departure_id β enables boarding point lookup |
boarding_point_id | UUID | Nullable. Resolved from service_legs.boarding_point_id for PICKUP legs. Enables downstream passenger targeting ([v0.2] β requires boarding_order, see note). |
WARNING
Schema change: backoffice.boarding_points has been replaced by boarding_point_library (single operator-level library with optional door pickup per stop). The boarding_order column does not exist at V0.1 β it is deferred to [v0.2] as a dispatch-side concern. The downstream passenger targeting logic (comparing boarding_order to filter passengers at stops after the incident) must be redesigned when boarding order lands. See boarding-points.md. | severity | String | LOW, MEDIUM, CRITICAL | | type | String | DELAY, BREAKDOWN, PASSENGER_ISSUE | | description | String | Driver's free-text description | | geo_coordinates | JSONB | GPS pin { lat, lng } | | reporter_crew_id | UUID | Soft FK to backoffice.crew_members. The driver who reported. | | recalculated_eta | TIMESTAMPTZ | Nullable. ETA for the next downstream boarding stop (populated if type=DELAY and ETA service has run). Null on initial creation if ETA recalculation is async. | | occurred_at | TIMESTAMPTZ | When the incident happened |
IncidentResolved β
| Field | Type | Description |
|---|---|---|
event_id | UUID | Idempotency key |
tenant_id | UUID | Owning operator |
incident_id | UUID | The resolved incident |
service_leg_id | UUID | Impacted leg |
tour_offering_id | UUID | For passenger re-query |
tour_departure_id | UUID | For boarding point lookup |
severity | String | Original severity (determines whether the system sends an all-clear message) |
type | String | Incident type |
resolution_notes | String | Dispatcher's resolution summary |
resolved_at | TIMESTAMPTZ | When resolved |
Delivery Contracts β
| Event | Mechanism | Trigger | Idempotency |
|---|---|---|---|
IncidentCreated | Hasura Event Trigger (async, post-commit) β NestJS webhook | operations.incidents INSERT | event_id |
IncidentResolved | Hasura Event Trigger (async, post-commit) β NestJS webhook | operations.incidents.status β RESOLVED | event_id |
All consumers implement at-least-once processing with event_id-based deduplication.
Enrichment pattern: The NestJS webhook handler for the Hasura Event Trigger on
incidentsINSERT must enrich the raw row payload by joiningservice_legs(fortour_offering_id,tour_departure_id,boarding_point_id) and optionally the latestroute_waypoints.eta(forrecalculated_eta). This follows the same pattern used byServiceLegDelayedwhich includesrecalculated_etanot present on theservice_legsrow itself.
API Endpoints β
Consumer ETA Tracking β
Endpoint: GET /api/track/:tracking_tokenAuth: None (public). Token is a signed, time-limited JWT containing { service_leg_id, tenant_id, exp }. Rate limit: 1 req/5s per token.
Response:
| Field | Type | Source |
|---|---|---|
vehicle_position | { lat, lng } | Latest telemetry_points row for the leg's vehicle |
speed_kmh | DECIMAL | Latest telemetry_points.speed |
next_stop_name | STRING | Next route_waypoints.label where waypoint_type = BOARDING_STOP and sequence_order > current |
next_stop_eta | TIMESTAMP | route_waypoints.eta for the next boarding stop |
leg_status | STRING | service_legs.status (ACTIVE, DELAYED, COMPLETED) |
updated_at | TIMESTAMP | telemetry_points.recorded_at |
Delivery: The passenger's Apple/Google Wallet pass and the WhatsApp tracking link both embed this token. The tracking page polls every 10s. Phase 2: upgrade to Server-Sent Events (SSE) for push updates.
IssueReport Lifecycle Events β
For the IssueReport table definition and Hasura Actions, see schema-operations.md Β§issue_reports. For the cross-context maintenance feedback loop, see ADR-008.
Event Catalog β
| Event | Emitter | Consumer | Purpose |
|---|---|---|---|
VehicleMaintenanceRequired | Operations (issue_reports INSERT where maintenance_urgency = IMMEDIATE) | Backoffice (creates VehicleInspection) | Triggers cross-context maintenance feedback loop |
IssueReportCreated | Operations (issue_reports INSERT) | Backoffice (dispatch board feed) | Signals new field observation for dispatcher awareness |
VehicleInspectionScheduled | Backoffice (vehicle_inspections INSERT) | Operations (transitions IssueReport β IN_PROGRESS) | Signals maintenance inspection scheduled for the originating IssueReport |
VehicleInspectionCompleted | Backoffice (vehicle_inspections.status β COMPLETED) | Operations (transitions IssueReport β RESOLVED) | Signals maintenance inspection completed |
VehicleMaintenanceRequired β
| Field | Type | Description |
|---|---|---|
event_id | UUID | Idempotency key |
tenant_id | UUID | Owning operator |
issue_report_id | UUID | Originating IssueReport |
vehicle_id | UUID | Target vehicle |
category | String | MECHANICAL, CLEANLINESS, DAMAGE |
maintenance_urgency | String | IMMEDIATE |
description | String | Issue description |
has_attachments | Boolean | Whether the driver attached photos/videos |
Delivery: Hasura Event Trigger on issue_reports INSERT where maintenance_urgency = IMMEDIATE. NestJS consumer in Backoffice creates a VehicleInspection row and links the originating issue_report_id via the vehicle_inspection_issue_reports join table (for the feedback loop). If an open VehicleInspection already exists for the same vehicle_id, the consumer links the new IssueReport to the existing inspection instead of creating a new one (dedup β see schema-operations.md Β§Duplicate IssueReport Dedup).
IssueReportCreated β
| Field | Type | Description |
|---|---|---|
event_id | UUID | Idempotency key |
tenant_id | UUID | Owning operator |
issue_report_id | UUID | Created IssueReport |
vehicle_id | UUID | Target vehicle |
reporter_crew_id | UUID | Reporting driver |
category | String | MECHANICAL, CLEANLINESS, DAMAGE |
maintenance_urgency | String | NONE, IMMEDIATE, SCHEDULED |
description | String | Truncated to 200 chars for lightweight payload |
Delivery: Hasura Event Trigger on issue_reports INSERT (async, post-commit). Consumer: Backoffice dispatch board feed.
VehicleInspectionScheduled β
| Field | Type | Description |
|---|---|---|
event_id | UUID | Idempotency key |
tenant_id | UUID | Owning operator |
vehicle_inspection_id | UUID | Created inspection |
vehicle_id | UUID | Target vehicle |
issue_report_id | UUID | Originating IssueReport (from vehicle_inspection_issue_reports join table) |
scheduled_date | DATE | The date the system scheduled the inspection |
Delivery: Fan-out β one event per linked IssueReport. On backoffice.vehicle_inspections INSERT, the Backoffice NestJS handler iterates vehicle_inspection_issue_reports rows and emits one VehicleInspectionScheduled event per row. Each event carries a single issue_report_id. The Operations consumer processes each independently: UPDATE issue_reports SET status = 'IN_PROGRESS' WHERE id = issue_report_id AND status = 'OPEN'. Guard: if status β OPEN, log warning and skip (see schema-operations.md Β§Backward Transition Guards).
VehicleInspectionCompleted β
| Field | Type | Description |
|---|---|---|
event_id | UUID | Idempotency key |
tenant_id | UUID | Owning operator |
vehicle_inspection_id | UUID | Completed inspection |
vehicle_id | UUID | Target vehicle |
issue_report_id | UUID | Originating IssueReport |
completed_date | TIMESTAMPTZ | When completed |
outcome | String | PASSED (no further action), DEFECT_CONFIRMED (repair needed), DEFECT_NOT_FOUND |
Delivery: Fan-out β one event per linked IssueReport. On backoffice.vehicle_inspections UPDATE where status β COMPLETED, the Backoffice NestJS handler iterates vehicle_inspection_issue_reports rows and emits one VehicleInspectionCompleted event per row. The Operations consumer processes each independently: UPDATE issue_reports SET status = 'RESOLVED' WHERE id = issue_report_id AND status = 'IN_PROGRESS'. Guard: if status β IN_PROGRESS, log warning and skip.
Delivery Contracts β
| Event | Mechanism | Trigger | Idempotency |
|---|---|---|---|
VehicleMaintenanceRequired | Hasura Event Trigger (async, post-commit) β NestJS webhook | operations.issue_reports INSERT where maintenance_urgency = IMMEDIATE | event_id |
IssueReportCreated | Hasura Event Trigger (async, post-commit) β NestJS webhook | operations.issue_reports INSERT | event_id |
VehicleInspectionScheduled | Fan-out: Hasura Event Trigger β NestJS emitter (one event per linked IssueReport) | backoffice.vehicle_inspections INSERT | event_id |
VehicleInspectionCompleted | Fan-out: Hasura Event Trigger β NestJS emitter (one event per linked IssueReport) | backoffice.vehicle_inspections.status β COMPLETED | event_id |
All consumers implement at-least-once processing with event_id-based deduplication.