Skip to content

DORA major incident reporting clock

Who this is for: a bank or other EU financial entity’s security and operations teams who owe the DORA (Digital Operational Resilience Act) major incident reports on a clock, and want a reminder before they miss a deadline, not an audit finding after.

Before you start: the company variable dora_reminder_email. No connections: this workflow only keeps the clock, it never files a report itself.

Your monitoring or ITSM system calls a webhook twice for each major incident: once when it’s classified as major (with incident_id and classified_at), and again each time a person sends the next required report (action: report_sent, no report type needed, since the clock already knows which report is next). Every 15 minutes, a schedule checks every open incident and emails a reminder for any deadline that is within an hour or already overdue, until the next report is sent.

platform/wait only waits up to 30 seconds, so this workflow can’t wait 4 hours inside one run. Instead, it uses two triggers on one workflow: the webhook records events and updates deadlines, and the schedule checks them, both against the same small saved state.

Data minimisation: the workflow keeps only an incident id and its deadline and “sent” timestamps in its state, nothing else about the incident. A closed incident (its final report sent) is removed from state entirely. This keeps the state well inside Liitora’s 64 KB per-value state limit for any realistic number of concurrent major incidents, and it means this workflow has nothing more sensitive to lose than a handful of ids and timestamps.

  • No connections and no secrets. This workflow only reads and writes its own state and sends email.
  • It emails only dora_reminder_email, a plain-text list of incident ids, stages and deadlines, nothing else about the incident.
  • Concurrency: concurrency.group keeps the webhook and the schedule from ever updating the same state at the same time.
  1. Add the company variable dora_reminder_email (who should get the reminders).
  2. Point your monitoring or ITSM system’s “incident classified” and “report sent” events at the workflow’s webhook endpoint, dora-incident (see Set up a webhook), sending incident_id and action always, and classified_at when action is classified.
  3. Take it into use from the Liitora library (see Take a library workflow into use), then read the test cases on your copy, run them and approve it.
  4. Read the caution above and confirm the deadlines with your own compliance team before you rely on the reminders.
  • Confirm the deadlines. Read the caution above first. If your interpretation of the rule differs, the three durations (4 hours, 72 hours, 1 month) are each in one place in the record_event script: change them there.
  • A different reminder window. The schedule checks for a deadline “within an hour or overdue”. Change the soon calculation in the check_deadlines script (and, if you want reminders less often than every 15 minutes, the cron) to match how much lead time your team wants.
  • Per-incident emails instead of one digest. If your team prefers one email per incident rather than one list of all of them, split the remind step into a for_each over steps.check_deadlines.outputs.due instead of one combined message.
  • A ticket instead of, or in addition to, an email. Add a step that opens or updates a ticket in your ITSM tool alongside (or instead of) the email, using the same due list.

Every 15 minutes, when the dora-incident webhook is called, or when someone starts it by hand: if its condition is met, check the incident event has an id, a valid action, and a classification time when needed. If its condition is met, check open incidents for a deadline due soon or overdue. If its condition is met, record the incident event and update its deadlines. Depending on the result of “Check open incidents for a deadline due soon or overdue”, build a plain-text summary of the incidents that need a reminder, then email a reminder for incidents with a deadline due soon or overdue.

What Details
Publisher Liitora
Version 1.0.0
Licence Apache-2.0
Content hash sha256:aad7bc24a05617af0125c2c036e3d3016f601ebe3f2533aca294100bad9df16f
Tests 4
Connections None
Company variables dora_reminder_email
Secrets None
Personal data None. The saved state holds only incident ids and timestamps.
Saved state Yes
Runs on your own runner No
Actions platform/email.send@v1, platform/evaluate@v1, platform/transform@v1

Liitora signs each version of this workflow. When your company takes it into use, Liitora checks the content hash and the signature again before it copies the workflow into your company. See How the library is kept safe.

# A Liitora library workflow for a bank CISO (see the guide, Library > DORA major incident
# reporting clock). No connections: this workflow only keeps the clock, it
# never files the reports itself. Company variable used:
# dora_reminder_email.
#
# This workflow keeps deadlines, it does not decide them. Check the
# timelines against your own DORA obligations before you rely on this
# workflow: see the README.md next to this file.
name: DORA major incident reporting clock
description: Keeps the DORA major incident reporting clock. A webhook from your monitoring or ITSM system records when an incident is classified as major or when a report is sent, and a schedule every 15 minutes emails a reminder when the next deadline is within an hour or overdue. People write and send the reports; this workflow only tracks the timing.
on:
webhook:
endpoint: dora-incident
schedule:
- cron: "*/15 * * * *"
timezone: UTC
manual: true
concurrency:
group: dora-incident-clock
cancel_in_progress: false
steps:
- id: validate_event
name: Check the incident event has an id, a valid action, and a classification time when needed
if: ${{ trigger.kind == 'webhook' }}
uses: platform/evaluate@v1
with:
expression: ${{ trigger.body.incident_id != null && trigger.body.incident_id != '' && (trigger.body.action == 'classified' || trigger.body.action == 'report_sent') && (trigger.body.action != 'classified' || (trigger.body.classified_at != null && trigger.body.classified_at != '')) }}
fail_if_false: true
message: "The DORA incident event needs incident_id, action (classified or report_sent), and classified_at when action is classified."
- id: record_event
name: Record the incident event and update its deadlines
if: ${{ trigger.kind == 'webhook' }}
run: |
export default async function run(ctx) {
const body = ctx.event.body;
const incidents = (await ctx.state.get("dora_incidents")) ?? {};
const id = String(body.incident_id);
const now = ctx.now();
if (body.action === "classified") {
const classifiedAt = new Date(body.classified_at);
const initialDue = new Date(classifiedAt.getTime() + 4 * 3600 * 1000);
incidents[id] = {
classified_at: classifiedAt.toISOString(),
initial_due: initialDue.toISOString(),
initial_sent_at: null,
intermediate_due: null,
intermediate_sent_at: null,
final_due: null,
final_sent_at: null,
};
await ctx.state.set("dora_incidents", incidents);
return { incident_id: id, action: "classified" };
}
if (body.action === "report_sent") {
const incident = incidents[id];
if (!incident) {
return ctx.skip(`no open incident ${id} to record a report for`);
}
if (!incident.initial_sent_at) {
incident.initial_sent_at = now.toISOString();
incident.intermediate_due = new Date(now.getTime() + 72 * 3600 * 1000).toISOString();
} else if (!incident.intermediate_sent_at) {
incident.intermediate_sent_at = now.toISOString();
const due = new Date(now);
due.setUTCMonth(due.getUTCMonth() + 1);
incident.final_due = due.toISOString();
} else if (!incident.final_sent_at) {
incident.final_sent_at = now.toISOString();
delete incidents[id];
await ctx.state.set("dora_incidents", incidents);
return { incident_id: id, action: "report_sent", stage: "final", closed: true };
} else {
return ctx.skip(`incident ${id} is already closed`);
}
await ctx.state.set("dora_incidents", incidents);
return { incident_id: id, action: "report_sent" };
}
return ctx.skip(`unknown action ${body.action}`);
}
- id: check_deadlines
name: Check open incidents for a deadline due soon or overdue
needs: []
if: ${{ trigger.kind == 'cron' }}
run: |
export default async function run(ctx) {
const incidents = (await ctx.state.get("dora_incidents")) ?? {};
const now = ctx.now();
const soon = new Date(now.getTime() + 3600 * 1000);
const due = [];
for (const [id, incident] of Object.entries(incidents)) {
const stages = [
["initial notification", incident.initial_due, incident.initial_sent_at],
["intermediate report", incident.intermediate_due, incident.intermediate_sent_at],
["final report", incident.final_due, incident.final_sent_at],
];
for (const [stage, deadline, sentAt] of stages) {
if (deadline && !sentAt) {
const deadlineDate = new Date(deadline);
if (deadlineDate <= soon) {
due.push({ incident_id: id, stage, deadline, overdue: deadlineDate <= now });
}
break;
}
}
}
return { due, count: due.length };
}
- id: build_message
name: Build a plain-text summary of the incidents that need a reminder
needs: [check_deadlines]
if: ${{ steps.check_deadlines.outputs.due.length > 0 }}
uses: platform/transform@v1
with:
input:
due: ${{ steps.check_deadlines.outputs.due }}
script: |
return input.due
.map((d) => `${d.overdue ? "Overdue" : "Due within an hour"}: incident ${d.incident_id}, ${d.stage}, deadline ${d.deadline}`)
.join("\n");
- id: remind
name: Email a reminder for incidents with a deadline due soon or overdue
needs: [build_message]
if: ${{ steps.check_deadlines.outputs.due.length > 0 }}
uses: platform/email.send@v1
with:
to: ["${{ vars.dora_reminder_email }}"]
subject: "DORA incident clock: ${{ steps.check_deadlines.outputs.count }} deadline(s) due soon or overdue"
text: "${{ steps.build_message.outputs.result }}"
  • 1.0.0 (2026-10-04): First version in the library.