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.
What it does
Section titled “What it does”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.
What it can access
Section titled “What it can access”- 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.groupkeeps the webhook and the schedule from ever updating the same state at the same time.
What you need to set up
Section titled “What you need to set up”- Add the company variable
dora_reminder_email(who should get the reminders). - 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), sendingincident_idandactionalways, andclassified_atwhenactionisclassified. - 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.
- Read the caution above and confirm the deadlines with your own compliance team before you rely on the reminders.
How to adapt it
Section titled “How to adapt it”- 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_eventscript: change them there. - A different reminder window. The schedule checks for a deadline “within an hour or overdue”. Change the
sooncalculation in thecheck_deadlinesscript (and, if you want reminders less often than every 15 minutes, thecron) 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
remindstep into afor_eachoversteps.check_deadlines.outputs.dueinstead 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
duelist.
What Liitora shows on the approval page
Section titled “What Liitora shows on the approval page”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.
Trust details
Section titled “Trust details”| 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.
The full YAML
Section titled “The full YAML”# 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 clockdescription: 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 }}"Changelog
Section titled “Changelog”- 1.0.0 (2026-10-04): First version in the library.