Skip to content

OCC Service Disruption SOP

The incident-response protocol the Operations Control Centre (OCC) follows when a train service disruption is detected. This is the most complex example — it combines multi-role guards, parallel notification branches, and a wait state delegating to an external bus-bridging SOP.

Features shown: Guard.inject with role-based auth context, fork + join + WaitState in one workflow, resolveWait with an external snapshot, JsonGraphExporter.

Workflow diagram

incident-detected
     │ VERIFY  (guard: isController)

incident-verified
     │ ESCALATE_TO_DM  (guard: isController)

duty-manager-notified
     │ AUTHORISE_RESPONSE  (guard: isDutyManager)

response-authorised ──START_NOTIFICATIONS──▶ notification-fork ⑂
                                             /        |          \
                                       ops-team  stn-masters  public-comms
                                             \        |          /
                                        notification-join ⑁ (all)
                                                  │ START_BUS_BRIDGE  (guard: isDutyManager)

                                           bus-bridging ⤴  (WaitState)
                                                  │ BUS_BRIDGE_ACTIVE

                                           service-disrupted
                                                  │ SERVICE_RESTORED  (guard: isController)

                                           service-restored
                                                  │ FILE_REPORT  (guard: isSupervisor)

                                           incident-closed ✓

Full code

ts
import { z } from 'zod';
import { createWorkflow, Guard } from 'flowyd';
import { MermaidExporter, JsonGraphExporter } from 'flowyd/visualization';

// ── Domain types ────────────────────────────────────────────────────────────

type OccRole = 'controller' | 'duty-manager' | 'supervisor' | 'station-master';

interface OccActor {
  staffId: string;
  role: OccRole;
}

// ── Action schemas ──────────────────────────────────────────────────────────

const ActorRef = z.object({ staffId: z.string(), role: z.string() });

const IncidentVerifySchema = z.object({
  verifiedBy: ActorRef,
  incidentType: z.enum([
    'signal-fault',
    'train-breakdown',
    'door-fault',
    'track-obstruction',
    'power-failure',
  ]),
  affectedLine: z.string(),
  affectedKm: z.number(),
  summary: z.string(),
});

const EscalateSchema = z.object({
  escalatedBy: ActorRef,
  dmStaffId: z.string(),
  urgency: z.enum(['P1', 'P2', 'P3']),
});

const AuthoriseSchema = z.object({
  authorisedBy: ActorRef,
  responseType: z.enum(['bus-bridging', 'short-working', 'single-line-working']),
});

const StartNotificationsSchema = z.object({ startedBy: ActorRef });

const NotifyOpsSchema = z.object({
  notifiedBy: z.object({ staffId: z.string() }),
  channel: z.string(),
  confirmedAt: z.string(),
});

const NotifyStnMastersSchema = z.object({
  notifiedBy: z.object({ staffId: z.string() }),
  stationsCount: z.number(),
  method: z.string(),
});

const NotifyPublicSchema = z.object({
  notifiedBy: z.object({ staffId: z.string() }),
  channelsUsed: z.array(z.string()),
});

const StartBusBridgeSchema = z.object({
  authorisedBy: ActorRef,
  busBridgeRef: z.string(),
});

const BusBridgeActiveSchema = z.object({
  confirmedBy: z.object({ staffId: z.string() }),
  busCount: z.number(),
  firstBusAt: z.string(),
});

const ServiceRestoredSchema = z.object({
  confirmedBy: ActorRef,
  restoredAt: z.string(),
  remarks: z.string(),
});

const FileReportSchema = z.object({
  filedBy: ActorRef,
  reportRef: z.string(),
  rootCause: z.string(),
  duration: z.number(), // minutes
});

// ── Workflow definition ─────────────────────────────────────────────────────

const occDisruptionSop = createWorkflow({ name: 'occ-disruption-sop' })
  .defineAction('VERIFY', IncidentVerifySchema)
  .defineAction('ESCALATE_TO_DM', EscalateSchema)
  .defineAction('AUTHORISE_RESPONSE', AuthoriseSchema)
  .defineAction('START_NOTIFICATIONS', StartNotificationsSchema)
  .defineAction('NOTIFY_OPS_TEAM', NotifyOpsSchema)
  .defineAction('NOTIFY_STN_MASTERS', NotifyStnMastersSchema)
  .defineAction('NOTIFY_PUBLIC', NotifyPublicSchema)
  .defineAction('START_BUS_BRIDGE', StartBusBridgeSchema)
  .defineAction('BUS_BRIDGE_ACTIVE', BusBridgeActiveSchema)
  .defineAction('SERVICE_RESTORED', ServiceRestoredSchema)
  .defineAction('FILE_REPORT', FileReportSchema)

  .addStep('incident-detected', { label: 'Incident Detected' })
  .addStep('incident-verified', { label: 'Incident Verified' })
  .addStep('duty-manager-notified', { label: 'DM Notified' })
  .addStep('response-authorised', { label: 'Response Authorised' })
  .addStep('ops-team', { label: 'Ops Team Notified' })
  .addStep('stn-masters', { label: 'Station Masters Notified' })
  .addStep('public-comms', { label: 'Public Comms Notified' })
  .addFork('notification-fork', { targets: ['ops-team', 'stn-masters', 'public-comms'] })
  .addJoin('notification-join', {
    requires: ['ops-team', 'stn-masters', 'public-comms'],
    mode: 'all',
  })
  .addWait('bus-bridging', { externalName: 'bus-bridging-sop' })
  .addStep('service-disrupted', { label: 'Service Disrupted (Managed)' })
  .addStep('service-restored', { label: 'Service Restored' })
  .addStep('incident-closed', { label: 'Incident Closed' })

  .setInitial('incident-detected')
  .setTerminal(['incident-closed'])

  .addTransition({
    from: 'incident-detected',
    to: 'incident-verified',
    on: 'VERIFY',
    guard: Guard.inject('isController'),
  })
  .addTransition({
    from: 'incident-verified',
    to: 'duty-manager-notified',
    on: 'ESCALATE_TO_DM',
    guard: Guard.inject('isController'),
  })
  .addTransition({
    from: 'duty-manager-notified',
    to: 'response-authorised',
    on: 'AUTHORISE_RESPONSE',
    guard: Guard.inject('isDutyManager'),
  })
  .addTransition({
    from: 'response-authorised',
    to: 'notification-fork',
    on: 'START_NOTIFICATIONS',
  })
  .addTransition({ from: 'ops-team', to: 'notification-join', on: 'NOTIFY_OPS_TEAM' })
  .addTransition({ from: 'stn-masters', to: 'notification-join', on: 'NOTIFY_STN_MASTERS' })
  .addTransition({ from: 'public-comms', to: 'notification-join', on: 'NOTIFY_PUBLIC' })
  .addTransition({
    from: 'notification-join',
    to: 'bus-bridging',
    on: 'START_BUS_BRIDGE',
    guard: Guard.inject('isDutyManager'),
  })
  .addTransition({ from: 'bus-bridging', to: 'service-disrupted', on: 'BUS_BRIDGE_ACTIVE' })
  .addTransition({
    from: 'service-disrupted',
    to: 'service-restored',
    on: 'SERVICE_RESTORED',
    guard: Guard.inject('isController'),
  })
  .addTransition({
    from: 'service-restored',
    to: 'incident-closed',
    on: 'FILE_REPORT',
    guard: Guard.inject('isSupervisor'),
  })

  .build();

// ── Execution ───────────────────────────────────────────────────────────────

async function runDisruptionSop() {
  // Personnel on shift
  const ctrl: OccActor = { staffId: 'OCC-C01', role: 'controller' };
  const dm: OccActor = { staffId: 'OCC-D03', role: 'duty-manager' };
  const supv: OccActor = { staffId: 'OCC-S01', role: 'supervisor' };

  const inst = occDisruptionSop.createInstance('INC-20240520-0042');

  // The guards read from a `currentActor` closure that simulates request context.
  // In production, read from the authenticated user's JWT/session.
  let currentActor: OccActor | null = null;

  inst
    .injectGuard('isController', async () => currentActor?.role === 'controller')
    .injectGuard('isDutyManager', async () => currentActor?.role === 'duty-manager')
    .injectGuard('isSupervisor', async () => currentActor?.role === 'supervisor');

  // Step 1: Controller verifies the incident
  currentActor = ctrl;
  await inst.dispatch('VERIFY', {
    verifiedBy: { staffId: ctrl.staffId, role: ctrl.role },
    incidentType: 'signal-fault',
    affectedLine: 'NS',
    affectedKm: 18.4,
    summary: 'Signal failure at Jurong East junction',
  });
  console.log(inst.getCurrentStates()); // ['incident-verified']

  // Step 2: Controller escalates to Duty Manager
  await inst.dispatch('ESCALATE_TO_DM', {
    escalatedBy: { staffId: ctrl.staffId, role: ctrl.role },
    dmStaffId: dm.staffId,
    urgency: 'P1',
  });

  // Step 3: DM authorises the response
  currentActor = dm;
  await inst.dispatch('AUTHORISE_RESPONSE', {
    authorisedBy: { staffId: dm.staffId, role: dm.role },
    responseType: 'bus-bridging',
  });

  // Step 4: Start all three notification streams simultaneously
  currentActor = ctrl;
  await inst.dispatch('START_NOTIFICATIONS', {
    startedBy: { staffId: ctrl.staffId, role: ctrl.role },
  });
  console.log(inst.getCurrentStates()); // ['ops-team', 'stn-masters', 'public-comms']

  // Step 5: Each stream completes (order does not matter)
  await inst.dispatch('NOTIFY_STN_MASTERS', {
    notifiedBy: { staffId: ctrl.staffId },
    stationsCount: 5,
    method: 'OCC-intercom',
  });
  await inst.dispatch('NOTIFY_PUBLIC', {
    notifiedBy: { staffId: ctrl.staffId },
    channelsUsed: ['display-boards', 'twitter'],
  });
  await inst.dispatch('NOTIFY_OPS_TEAM', {
    notifiedBy: { staffId: ctrl.staffId },
    channel: 'radio',
    confirmedAt: new Date().toISOString(),
  });
  // JoinState activates automatically after the third notification
  console.log(inst.getCurrentStates()); // ['notification-join']

  // Step 6: DM authorises bus bridging — enters WaitState
  currentActor = dm;
  await inst.dispatch('START_BUS_BRIDGE', {
    authorisedBy: { staffId: dm.staffId, role: dm.role },
    busBridgeRef: 'BB-001',
  });
  console.log(inst.getCurrentStates()); // ['bus-bridging'] — status: 'waiting'

  // Step 7: Bus-bridging SOP completes externally.
  // In production: a separate WorkflowInstance runs the bus-bridging SOP.
  // When it reaches terminal, your service calls resolveWait().
  const externalSnap = occDisruptionSop.createInstance('BB-001').getSnapshot();
  inst.resolveWait('bus-bridging', externalSnap);
  console.log(inst.getCurrentStates()); // ['bus-bridging'] — status now 'active'

  // Step 8: Confirm buses are in position
  currentActor = ctrl;
  await inst.dispatch('BUS_BRIDGE_ACTIVE', {
    confirmedBy: { staffId: ctrl.staffId },
    busCount: 12,
    firstBusAt: new Date().toISOString(),
  });

  // Step 9: Service restored
  await inst.dispatch('SERVICE_RESTORED', {
    confirmedBy: { staffId: ctrl.staffId, role: ctrl.role },
    restoredAt: new Date().toISOString(),
    remarks: 'Signal equipment replaced. Test runs completed.',
  });

  // Step 10: Supervisor files the post-incident report
  currentActor = supv;
  await inst.dispatch('FILE_REPORT', {
    filedBy: { staffId: supv.staffId, role: supv.role },
    reportRef: 'PIR-20240520-0042',
    rootCause: 'Degraded signal relay — scheduled replacement overdue by 14 days',
    duration: 87,
  });

  const snap = inst.getSnapshot();
  console.log(inst.isTerminal()); // true
  console.log(snap.version); // 12
  console.log(snap.history.length); // 12

  // ── Guard block demo ─────────────────────────────────────────────────────
  // A station master cannot verify an incident — only controllers can
  const blocked = occDisruptionSop.createInstance('INC-BLOCKED');
  let blockedActor: OccActor | null = null;
  blocked
    .injectGuard('isController', async () => blockedActor?.role === 'controller')
    .injectGuard('isDutyManager', async () => blockedActor?.role === 'duty-manager')
    .injectGuard('isSupervisor', async () => blockedActor?.role === 'supervisor');

  blockedActor = { staffId: 'STN-M99', role: 'station-master' };
  const denied = await blocked.dispatch('VERIFY', {
    verifiedBy: { staffId: 'STN-M99', role: 'station-master' },
    incidentType: 'track-obstruction',
    affectedLine: 'EW',
    affectedKm: 5.1,
    summary: 'Object on track',
  });
  console.log(denied.success); // false
  console.log(!denied.success && denied.reason); // 'guard-failed'
  console.log(blocked.getCurrentStates()); // ['incident-detected'] — unchanged

  // ── JSON graph for dashboard integration ─────────────────────────────────
  const graph = JsonGraphExporter.export(occDisruptionSop.getDefinition());
  console.log(`Nodes: ${graph.nodes.length}, Edges: ${graph.edges.length}`);
  const guardedTransitions = graph.edges.filter((e) => e.hasGuard).map((e) => e.action);
  console.log('Guarded:', guardedTransitions.join(', '));
}

runDisruptionSop().catch(console.error);

What to notice

Named guards simulate auth context via closure. The currentActor variable mimics what a real system gets from a request context (JWT, session). The guards themselves stay pure — they just read from their injection closure.

Fork + join + wait in sequence. This example chains all three "automatic" state types: the fork fans out, the join re-synchronises, and the wait state pauses the workflow for an external SOP. All three resolve without the caller needing extra dispatches.

resolveWait accepts an external snapshot. The optional second argument stores the bus-bridging SOP's final snapshot inside the parent's audit history. This gives a complete audit trail across both workflows.

JsonGraphExporter exposes guard metadata. The hasGuard flag on each edge lets a dashboard highlight which transitions require authorization, useful for building access-aware UI affordances.

Released under the MIT License.