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 โ
| Field | Value |
|---|---|
| Auth | MANAGER or DISPATCHER role (or DISPATCH capability) |
| Input | SwapVehicleInput (see below) |
| Success | SwapVehicleResult (see below) |
| Errors | ASSIGNMENT_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 |
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 SwapVehicleResultVehicleSwapped Domain Event โ
Registered in the Domain Events Catalog (ยง8).
| Field | Value |
|---|---|
| Event | VehicleSwapped |
| Emitter | Operations (via SwapVehicle NestJS handler) |
| Consumer | Communications (passenger notification) |
| Trigger | leg_assignments.vehicle_id updated |
| Payload | { tenant_id, leg_assignment_id, service_leg_id, old_vehicle_id, new_vehicle_id, remapping_report } |
| Consumer action | Communications 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 resultPriority rules:
- The algorithm remaps
WHEELCHAIRreservations first (accessibility obligation) - The algorithm remaps
PREMIUMreservations second STANDARDreservations use remaining seats- If the algorithm cannot match a priority passenger to their type, it downgrades them (warning emitted) instead of releasing them
- Released passengers receive a notification with rebooking options
Edge States โ
| # | Edge Case | Resolution | Enforcement |
|---|---|---|---|
| E1 | New vehicle has fewer seats than confirmed reservations | Pre-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. |
| E2 | Swap 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. |
| E3 | WHEELCHAIR seats don't exist on new vehicle | The 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. |
| E4 | Commerce remapping fails mid-transaction | Since 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. |
| E5 | Concurrent swap: two dispatchers swap the same assignment | The 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. |
| E6 | New 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. |
| E7 | Swap to a vehicle with different transmission type | After 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. |
| E8 | Swap 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 โ
| Table | Schema Doc | Detail |
|---|---|---|
leg_assignments | schema-operations.md | vehicle_id soft FK, row-level lock for concurrent swaps |
seat_reservations | schema-commerce.md | seat_identifier uniqueness constraint, RELEASED status |
vehicles | schema-backoffice.md | seat_map_layout JSONB, vehicle_class, transmission_type |
vehicle_inspections | schema-backoffice.md | blocks_dispatch flag checked during validation |
crew_qualifications | schema-backoffice.md | restriction_type = 'AUTOMATIC_ONLY' checked for E7 |
notification_templates | schema-backoffice.md | VEHICLE_SWAP_NOTIFICATION trigger_event |