Busflow Docs

Internal documentation portal

Skip to content

Vehicle Swap & Seat Remapping Protocol โ€‹

This document specifies the Vehicle Swap cross-context protocol. A Hasura Action reassigns a vehicle on an active LegAssignment and remaps all affected SeatReservation records to the new vehicle's seat map. The operation spans the Operations and Commerce bounded contexts within a single ACID transaction.

Contexts involved: Operations (assignment mutation) ยท Commerce (seat remapping) ยท Communications (passenger notification) DDD pattern: Saga coordination as synchronous domain service (DDD ยง7.3)


SwapVehicle Hasura Action โ€‹

FieldValue
AuthMANAGER or DISPATCHER role (or DISPATCH capability)
InputSwapVehicleInput (see below)
SuccessSwapVehicleResult (see below)
ErrorsASSIGNMENT_NOT_FOUND ยท VEHICLE_NOT_FOUND ยท VEHICLE_NOT_ACTIVE ยท VEHICLE_DISPATCH_BLOCKED ยท LEG_ALREADY_COMPLETED ยท CAPACITY_INSUFFICIENT ยท REMAP_FAILED ยท ASSIGNMENT_ALREADY_MODIFIED ยท CANNOT_SWAP_SUBCONTRACTED_LEG
typescript
interface SwapVehicleInput {
  leg_assignment_id: UUID;
  new_vehicle_id: UUID;
  force_capacity_override?: boolean; // Allow swap even if new vehicle has fewer seats
}

interface SwapVehicleResult {
  success: boolean;
  assignment: {
    id: UUID;
    old_vehicle_id: UUID;
    new_vehicle_id: UUID;
  };
  remapping: SeatRemappingReport;
  warnings: SwapWarning[];
}

interface SeatRemappingReport {
  strategy_used: 'MATCH_BY_ID' | 'MATCH_BY_POSITION' | 'REASSIGN_BY_TYPE';
  total_reservations: number;
  successfully_remapped: number;
  released: number;          // Seats that couldn't be remapped
  released_passengers: {     // Passengers who lost their seats
    passenger_id: UUID;
    old_seat: string;
    reason: 'SEAT_NOT_FOUND' | 'TYPE_MISMATCH';
  }[];
}

interface SwapWarning {
  code: 'CAPACITY_REDUCTION' | 'CLASS_CHANGE' | 'TRANSMISSION_CHANGE' | 'RELEASED_SEATS';
  message: string;
  data: Record<string, unknown>;
}

Saga Choreography โ€‹

The swap executes as a single database transaction spanning two schema writes (Operations assignment, Commerce seat reservations) plus context-local audit entries. Since all schemas share one PostgreSQL instance, this is a regular ACID transaction โ€” no distributed saga compensation needed.

SwapVehicle Saga (synchronous, single transaction):

1. VALIDATE
   โ”œโ”€โ”€ Extract tenant_id from JWT claim `x-hasura-tenant-id` (not from input)
   โ”œโ”€โ”€ Fetch leg_assignment โ†’ verify exists, tenant_id matches
   โ”œโ”€โ”€ Fetch service_leg โ†’ verify status โˆˆ {SCHEDULED, ACTIVE, DELAYED}
   โ”œโ”€โ”€ Fetch new_vehicle โ†’ verify status = ACTIVE
   โ”œโ”€โ”€ Verify no `vehicle_inspections` row with `blocks_dispatch = true`
   โ”‚   for new_vehicle โ†’ else VEHICLE_DISPATCH_BLOCKED
   โ”œโ”€โ”€ Fetch old_vehicle seat_map_layout
   โ”œโ”€โ”€ Fetch new_vehicle seat_map_layout
   โ””โ”€โ”€ Capacity check: new_vehicle.capacity >= confirmed_reservation_count
       โ””โ”€โ”€ If insufficient AND force_capacity_override = false โ†’ ABORT

2. REMAP SEATS (Commerce)
   โ”œโ”€โ”€ Fetch all seat_reservations WHERE service_leg_id = leg.id
   โ”‚   AND status โˆˆ {HELD, CONFIRMED}
   โ”œโ”€โ”€ Execute remapping algorithm (see below)
   โ”œโ”€โ”€ UPDATE seat_reservations SET seat_identifier = new_seat
   โ”‚   WHERE remapping succeeded
   โ””โ”€โ”€ UPDATE seat_reservations SET status = RELEASED
       WHERE remapping failed (no matching seat on new vehicle)

3. UPDATE ASSIGNMENT (Operations)
   โ””โ”€โ”€ UPDATE leg_assignments SET vehicle_id = new_vehicle_id

4. EMIT EVENT
   โ””โ”€โ”€ INSERT domain event: VehicleSwapped
       Payload: { leg_assignment_id, old_vehicle_id, new_vehicle_id,
                  remapping_report, tenant_id }
       Note: Consumed via Hasura Event Trigger (fire-after-commit).
             If the transaction rolls back, the event row is discarded
             and no trigger fires.

5. AUDIT (via AuditTrailService โ€” see ADR-019)
   โ”‚   Generate correlation_id = UUIDv4() for cross-context tracing.
   โ”œโ”€โ”€ AuditTrailService.captureAndRecord(tx, {
   โ”‚     schema: 'operations',
   โ”‚     entityType: 'leg_assignment', entityId: assignment.id,
   โ”‚     action: UPDATE, scope: GENERAL,
   โ”‚     newValues: { vehicle_id: new },
   โ”‚     correlationId: correlation_id })
   โ””โ”€โ”€ AuditTrailService.record(tx, {
         schema: 'commerce',
         entityType: 'booking', entityId: booking.id,
         action: UPDATE, scope: GENERAL,
         oldValues: { remapped/released seats },
         newValues: { new seat_identifiers },
         correlationId: correlation_id })

6. RETURN SwapVehicleResult

VehicleSwapped Domain Event โ€‹

Registered in the Domain Events Catalog (ยง8).

FieldValue
EventVehicleSwapped
EmitterOperations (via SwapVehicle NestJS handler)
ConsumerCommunications (passenger notification)
Triggerleg_assignments.vehicle_id updated
Payload{ tenant_id, leg_assignment_id, service_leg_id, old_vehicle_id, new_vehicle_id, remapping_report }
Consumer actionCommunications sends seat change notifications to affected passengers (those whose seat_identifier changed or whose reservation the algorithm released). The provisioning flow must seed the template trigger_event VEHICLE_SWAP_NOTIFICATION in notification_templates during tenant onboarding. Template body and placeholder definitions are a Communications context concern.

Seat Remapping Algorithm โ€‹

When the vehicle changes, the system must remap existing seat_reservations to seats on the new vehicle. The algorithm uses a 3-phase cascade:

function remapSeats(reservations, old_seat_map, new_seat_map):
  result = { remapped: [], released: [] }
  unmatched = []

  // Phase 1: Match by seat ID (exact match)
  // If both vehicles use the same seat labeling scheme (e.g., "1A", "1B"),
  // seats transfer directly.
  // Invariant: seat_identifier is unique per (service_leg_id, status โˆˆ {HELD, CONFIRMED})
  // โ€” guaranteed by the booking flow. Phase 1 cannot produce duplicate assignments.
  for reservation in reservations:
    new_seat = new_seat_map.find(s => s.id == reservation.seat_identifier)
    if new_seat is not null:
      result.remapped.push({ reservation, new_seat: new_seat.id })
    else:
      unmatched.push(reservation)

  // Sort unmatched by type priority so WHEELCHAIR passengers claim seats first,
  // then PREMIUM, then STANDARD โ€” per priority rules below.
  unmatched = unmatched.sort(by_type_priority: WHEELCHAIR > PREMIUM > STANDARD)

  // Phase 2: Match by type priority (WHEELCHAIR > PREMIUM > STANDARD)
  // For unmatched seats, find the closest equivalent by type.
  for reservation in unmatched:
    old_seat = old_seat_map.find(s => s.id == reservation.seat_identifier)
    if old_seat is null:
      result.released.push({ reservation, reason: 'SEAT_NOT_FOUND' })
      continue

    // Find available seat of same type
    candidate = new_seat_map.find(s =>
      s.type == old_seat.type
      AND s.id NOT IN result.remapped.map(r => r.new_seat)
    )
    if candidate is not null:
      result.remapped.push({ reservation, new_seat: candidate.id })
    else:
      // Phase 3: Downgrade โ€” wheelchair/premium passengers get any available seat
      if old_seat.type in ['WHEELCHAIR', 'PREMIUM']:
        fallback = new_seat_map.find(s =>
          s.id NOT IN result.remapped.map(r => r.new_seat)
        )
        if fallback is not null:
          result.remapped.push({ reservation, new_seat: fallback.id })
          // Emit warning: TYPE_MISMATCH
        else:
          result.released.push({ reservation, reason: 'SEAT_NOT_FOUND' })
      else:
        result.released.push({ reservation, reason: 'SEAT_NOT_FOUND' })

  return result

Priority rules:

  1. The algorithm remaps WHEELCHAIR reservations first (accessibility obligation)
  2. The algorithm remaps PREMIUM reservations second
  3. STANDARD reservations use remaining seats
  4. If the algorithm cannot match a priority passenger to their type, it downgrades them (warning emitted) instead of releasing them
  5. Released passengers receive a notification with rebooking options

Edge States โ€‹

#Edge CaseResolutionEnforcement
E1New vehicle has fewer seats than confirmed reservationsPre-swap capacity check: if new_vehicle.capacity < count(seat_reservations WHERE status = CONFIRMED), the system blocks the swap by default. The dispatcher can override with force_capacity_override: true โ†’ the remapping algorithm releases excess reservations (lowest-priority seats first: STANDARD, then PREMIUM). Communications notifies released passengers. The dispatcher sees a confirmation dialog: "3 passengers will lose their seat reservations. Continue?"Application โ€” NestJS guard + force_capacity_override flag. Frontend โ€” confirmation dialog.
E2Swap during active boarding (service_leg.status = ACTIVE)Allowed with ๐ŸŸก WARNING. The swap updates leg_assignments.vehicle_id and remaps seats, but the system does not remap already-boarded passengers (those who have a boarding_event with check_in_status = SUCCESS). Only un-boarded passengers undergo remapping. Warning text: "Boarding is in progress. The system will not remap already-boarded passengers."Application โ€” NestJS handler filters boarded_passenger_ids from remapping candidates. Frontend โ€” warning dialog.
E3WHEELCHAIR seats don't exist on new vehicleThe remapping algorithm prioritizes wheelchair passengers for any remaining accessible seat (accessible: true). If the new vehicle has no accessible seat: (1) the swap proceeds but the handler emits a ๐Ÿ”ด CRITICAL warning, (2) the system marks the affected passenger's reservation with a TYPE_MISMATCH flag, (3) the dispatcher must arrange alternatives (e.g., assign a different vehicle for the wheelchair-bound passenger). The system does not silently release wheelchair reservations.Application โ€” special handling in remapping algorithm Phase 2. Frontend โ€” critical warning requiring explicit acknowledgement.
E4Commerce remapping fails mid-transactionSince the saga runs as a single ACID transaction, any failure (e.g., constraint violation during seat_reservations UPDATE) rolls back the entire operation โ€” including the Operations assignment update. The system returns REMAP_FAILED error with details. No partial state exists.Database โ€” single PostgreSQL transaction. Application โ€” all writes within one NestJS database transaction.
E5Concurrent swap: two dispatchers swap the same assignmentThe first swap acquires a row-level lock on the leg_assignment row (SELECT ... FOR UPDATE). The second swap blocks until the first completes, then re-validates. If the first swap already changed the vehicle, the second swap sees the new state and either proceeds (if still valid) or fails with ASSIGNMENT_ALREADY_MODIFIED.Database โ€” SELECT ... FOR UPDATE on leg_assignments. Application โ€” re-validation after lock acquisition.
E6New vehicle is a different class (COACH โ†’ MINIBUS)The swap succeeds but emits a CLASS_CHANGE warning: "Vehicle class changed from COACH (49 seats) to MINIBUS (16 seats)." The dispatcher must confirm the class change. If the new vehicle's capacity is insufficient, the capacity check (E1) handles seat releases. No automatic route re-planning โ€” routing profile changes are a Phase 2 concern.Application โ€” warning generation in NestJS handler. Frontend โ€” confirmation dialog.
E7Swap to a vehicle with different transmission typeAfter updating leg_assignments.vehicle_id, the handler cross-checks the assigned crew member's qualifications. If any crew_qualifications row has restriction_type = 'AUTOMATIC_ONLY' and the new vehicle has transmission_type = 'MANUAL', the system emits a TRANSMISSION_CHANGE warning: "Assigned driver {name} has automatic-only restriction but new vehicle is manual." The swap still succeeds (the vehicle change is valid) but the system warns the dispatcher to reassign the crew member.Application โ€” post-swap qualification cross-check against crew_qualifications.restriction_type.
E8Swap for subcontracted leg (supplier_id IS NOT NULL)Blocked. Subcontracted legs have vehicle_id = NULL and crew_member_id = NULL. No internal vehicle exists to swap. Returns CANNOT_SWAP_SUBCONTRACTED_LEG error. To change the subcontractor's vehicle, the operator contacts the subcontractor directly โ€” this falls outside Busflow's scope.Application โ€” pre-validation check in NestJS handler.

Schema Cross-References โ€‹

TableSchema DocDetail
leg_assignmentsschema-operations.mdvehicle_id soft FK, row-level lock for concurrent swaps
seat_reservationsschema-commerce.mdseat_identifier uniqueness constraint, RELEASED status
vehiclesschema-backoffice.mdseat_map_layout JSONB, vehicle_class, transmission_type
vehicle_inspectionsschema-backoffice.mdblocks_dispatch flag checked during validation
crew_qualificationsschema-backoffice.mdrestriction_type = 'AUTOMATIC_ONLY' checked for E7
notification_templatesschema-backoffice.mdVEHICLE_SWAP_NOTIFICATION trigger_event

Internal documentation โ€” Busflow