Skip to content

Time Based Triggers

Ed Mozley edited this page Aug 2, 2026 · 3 revisions

Time-Based Triggers (SLA, contract & warranty)

"Escalate this before the SLA breaches." "Remind me 30 days before this contract ends."

These are the two automations people most want from an ITSM tool, and until #801 FreeITSM could not express either of them. This page explains why they were hard, how they work, and what you must schedule to make them run at all.

⚠️ The one thing to know first. These four triggers depend on cron jobs. If those aren't scheduled, the workflows sit there, active, and never fire β€” and nothing anywhere tells you why. See Setting up the crons.


Why these are different from every other trigger

Every other trigger in FreeITSM hangs off a write path. Someone saved a ticket, submitted a form, approved a change β€” the code that performs the write calls WorkflowEngine::dispatch() in the same request, synchronously. There was a moment to fire from.

"The SLA is about to breach" has no such moment.

Nothing happened. Time passed.

There is no save, no request, no actor β€” so there is nothing to hang a dispatch() off. Something has to go looking. That single fact is what makes this whole feature shaped the way it is.

The four triggers

Trigger Emitted by Schedule
sla.warning cron/sla_breach_check.php every 5 min
sla.breached cron/sla_breach_check.php every 5 min
contract.expiring cron/workflow_scheduled.php hourly
asset.warranty_expiring cron/workflow_scheduled.php hourly

The SLA events piggyback the cron that already exists. sla_breach_check.php already walks every open ticket and computes its SLA state, because it sends the SLA notification emails. Emitting workflow events from that same pass costs nothing extra β€” and, more importantly, cannot drift from what the SLA emails believe. Two independent implementations of "is this ticket breaching?" would eventually disagree, and you'd have no idea which one was right.

They are emitted before the email-rule lookup, deliberately: a workflow must fire whether or not anyone configured a notification rule. Notification rules decide who gets an email. They have nothing to say about automation.

Expiries fire at 90, 30, 7 and 1 days out. Each window is a separate emission carrying window_days in the payload β€” so "only remind me at 30 days" is a plain condition (window_days equals 30), not something your workflow has to schedule for itself.

The hard part isn't the detectors

Finding a breached SLA is easy. Not telling you about it 300 times is the feature.

A time-based condition stays true. A breached SLA is still breached five minutes later, and five minutes after that, and at 3am. Naive detection would re-fire the same escalation on every cron run, forever β€” which is worse than not having the feature, because you'd also have trained everyone to ignore the channel it shouts into.

So every emission goes through a ledger:

workflow_scheduled_emissions
  UNIQUE (trigger_event, entity_key, fingerprint)
  written with INSERT IGNORE

Only the insert that actually creates a row dispatches. The database is the arbiter, not the application β€” which means two overlapping cron runs cannot double-fire, no matter how they interleave. Getting this right in application code (check, then write) is a race; getting it right with a unique key is not.

The fingerprint β€” why plain "fire once" is wrong

The fingerprint is the state the emission was recorded against: an SLA's target in minutes, a contract's end date.

Change that state, and the situation is allowed to fire again:

  • Raise a ticket's priority β†’ its SLA target shrinks β†’ new fingerprint β†’ the new, tighter deadline is allowed to breach and escalate. Without this, the workflow would go quiet at exactly the moment the ticket became more urgent β€” the worst possible time to go quiet.
  • Renew a contract β†’ new end date β†’ new fingerprint β†’ next year's reminders still fire.

Without a fingerprint, "fire once" silently means "fire once ever, even if the thing you were watching changed underneath you."

Nothing is burned on an audience of nobody

If no active workflow is listening for an event, no ledger row is written.

This one was a real bug in the first implementation, caught in testing β€” and it's written up as Pitfalls #9. Recording emissions with nothing listening seems harmless and is catastrophic: the ledger would say "already fired" for every contract currently inside its window, so switching on a renewal workflow tomorrow would leave it silent for all of them. Enabling a workflow would appear to do nothing, for a reason you could never see.

Instead, the first cron run after you activate a workflow fires for everything currently in-window. Which is what anyone would expect.


Setting up the crons

Windows (Task Scheduler)

Program:    C:\wamp64\bin\php\php8.4.0\php.exe
Arguments:  C:\wamp64\www\freeitsm-app\cron\workflow_scheduled.php
Trigger:    Daily, repeat every 1 hour, indefinitely

Do the same for cron/sla_breach_check.php at every 5 minutes if you want the SLA triggers.

Linux (crontab)

*/5 * * * *  /usr/bin/php /var/www/freeitsm-app/cron/sla_breach_check.php    >/dev/null 2>&1
0   * * * *  /usr/bin/php /var/www/freeitsm-app/cron/workflow_scheduled.php  >/dev/null 2>&1

There is a 5-minute minimum interval between runs (workflow_cron_min_interval_seconds), which defeats double-scheduling and runaway loops. Running the script more often than that returns Rate limited rather than doing the work twice.

Full setup notes: docs/workflow-scheduled-cron-setup.md.

The starter recipes

Four templates in New from template exist only because of these triggers:

Recipe Trigger
Escalate before the SLA breaches sla.warning
Alert when an SLA is breached sla.breached
Contract renewal reminder contract.expiring
Asset warranty expiry reminder asset.warranty_expiring

The two expiry recipes ship filtered to the 30-day window (window_days equals 30). Change or drop that condition to be told at every window. This catches people: a contract 41 days out crosses the 90-day window today and won't match a 30-day filter β€” so the run is correctly logged as skipped, not failed.

⚠️ 2026-08-02: these had never actually fired

Worth knowing, because everything on this page described the design correctly and the code did not match it.

cron/sla_breach_check.php died the moment it was invoked β€” sla_format_minutes() was declared unguarded in both includes/sla.php and includes/sla_notifications.php, and the latter requires the former. A duplicate function declaration is a PHP fatal, so the cron never reached a single line of its own logic. sla.warning and sla.breached had therefore never fired, and no SLA email had ever sent, since the feature shipped on 17 May 2026 (#299).

Behind it sat two more faults in sla_emit_workflow_event(), both invisible because the fatal came first: the payload query selected type_id and created_by from tickets (they are ticket_type_id and user_id), and the requester lookup selected emails.from_email (it is from_address). Each would have thrown into a catch that logs and returns β€” silent.

The lesson for this page: a trigger being registered, documented and visible in the editor proves nothing about whether it fires. When you add one, make it fire β€” create a throwaway workflow, emit the event, read workflow_executions back. That is a two-minute check and it is the only one that would have caught this.

Testing it without waiting for a cron

Because nothing fires until something goes looking, this is the one feature that can't demonstrate itself. To prove it end to end:

  1. Build the workflow first, and activate it. Ordering matters β€” see audience of nobody above. Run the cron before the workflow exists and you'll simply get nothing.
  2. Make sure something is actually in-window (a contract with an end date inside 90 days).
  3. Run the cron by hand, rather than waiting for the scheduler:
    C:\wamp64\bin\php\php8.4.0\php.exe C:\wamp64\www\freeitsm-app\cron\workflow_scheduled.php
    
    Expect: OK β€” contract.expiring: 1, asset.warranty_expiring: 0, pruned 0 old emission(s).
  4. Check the execution log. The run's trigger-payload snapshot will show window_days and the record it fired against.
  5. Run it again. It should report 0 and create no second run β€” that's the ledger doing its job. (Inside 5 minutes you'll get Rate limited instead; that's the interval guard, not a failure.)
  6. Change the underlying date and run once more. It should fire again β€” that's the fingerprint re-arming, and it's the difference between "fire once" and "fire once ever, even if the world changed".

Adding a new time-based trigger

"Licence expiring in 30 days." "This CI hasn't been audited in a year." "A ticket has sat unassigned for 4 hours."

All of these are the same shape, and the machinery is generic β€” you do not need a new table, a new cron, or a new ledger. You need a detector and two well-chosen strings.

The five steps

1. Register the trigger

workflow/includes/engine.php β†’ availableTriggers(). Put it in the time-based block and say so in the label, because the editor's trigger picker is the only place a user learns this needs a cron:

'licence.expiring' => 'A software licence is approaching expiry (time-based)',

2. Write the detector

includes/workflow_scheduled.php. Query only what's inside the widest window, then walk the windows narrowest-first. Model it on workflowEmitContractExpiries():

function workflowEmitLicenceExpiries(PDO $conn): int
{
    $fired   = 0;
    $windows = workflowExpiryWindows();          // [90, 30, 7, 1]

    $rows = $conn->prepare(
        "SELECT id, name, expiry_date,
                DATEDIFF(expiry_date, CURDATE()) AS days_remaining
           FROM software_licences
          WHERE expiry_date IS NOT NULL
            AND expiry_date >= CURDATE()
            AND expiry_date <= DATE_ADD(CURDATE(), INTERVAL ? DAY)"
    );
    $rows->execute([max($windows)]);

    foreach ($rows->fetchAll(PDO::FETCH_ASSOC) as $l) {
        $days = (int)$l['days_remaining'];
        foreach ($windows as $w) {
            if ($days > $w) continue;            // hasn't entered this window yet
            $fired += workflowEmitOnce(
                $conn,
                'licence.expiring',
                'licence:' . (int)$l['id'] . ':' . $w,   // entity_key
                (string)$l['expiry_date'],               // fingerprint
                [
                    'licence'     => ['id' => (int)$l['id'], 'name' => $l['name'], 'end_date' => $l['expiry_date'], 'days_remaining' => $days],
                    'window_days' => $w,
                ]
            ) ? 1 : 0;
        }
    }
    return $fired;
}

3. Choose the entity key and the fingerprint β€” this is the whole job

Everything else is boilerplate. Get these two wrong and the feature is worse than useless.

Answers Rule of thumb
entity_key What is this about? The thing's stable identity β€” plus the window, if it has windows. contract:41:30
fingerprint Against what state? The value that, if it changed, must be allowed to fire again. A deadline, an end date, a target in minutes.

Ask yourself: "if a user changes something so this becomes newly urgent, must it fire again?" If yes, that something is the fingerprint.

The SLA case is the one that teaches it. Fingerprint on the ticket alone and raising a P3 to a P1 β€” which shrinks the SLA target β€” would leave the workflow silent, because it already fired against the old, laxer deadline. The automation would go quiet at exactly the moment the ticket got more urgent. Fingerprint on the target, and the tighter deadline re-arms.

4. Register the detector with the cron

workflowScheduledRun() β€” one line. The cron picks it up; nothing else to schedule:

return [
    'contract_expiring'       => workflowEmitContractExpiries($conn),
    'asset_warranty_expiring' => workflowEmitWarrantyExpiries($conn),
    'licence_expiring'        => workflowEmitLicenceExpiries($conn),   // ← new
];

5. Ship a starter recipe

workflow/includes/templates.php. A trigger nobody can find is a trigger nobody uses β€” the recipe is the discovery mechanism. Follow the Starter templates conventions ($lookup for install-specific ids, $configure for user-supplied values), and if it has windows, filter to one and say so in the description.

Where it does NOT belong

If your event has a write path, don't put it here. A licence deleted is a write β€” dispatch it from the service layer, synchronously, like every other trigger. Only put it in the scheduled cron when there is genuinely no moment to fire from.

The test: did something happen, or did time merely pass?

⚠️ A cron is not the same as time-based β€” the tracker.* case

The instinct to reuse this machinery is strongest for events that arrive on a cron but are not about time. The issue-tracker triggers (tracker.issue_status_changed, tracker.issue_comment_added) are the worked example: they are discovered by a poll, because a self-hosted install cannot be called back by Jira β€” but something happened. A developer moved the issue. Time did not merely pass.

They are dispatched from the point where the new state is already persisted, so they are edge-triggered by construction and need no ledger. Putting them through a fingerprint would be actively wrong: todo β†’ in_progress β†’ todo β†’ in_progress is three real transitions, and a fingerprint on current state would silently swallow the third.

See External Issue Trackers β€” Developer Guide Β§7f.

Changing or removing an existing trigger

The ledger is what makes these operations non-obvious. Three traps:

  • Changing the fingerprint formula re-arms everything. The old rows no longer match, so every record currently in-window is "new" again β€” the next cron run will fire for all of them at once. On a busy install that's a flood of tickets or chat messages. Expect it, and consider clearing the affected workflow_scheduled_emissions rows deliberately rather than being surprised.

  • Changing the window list (workflowExpiryWindows()) mints new entity_keys, so every record inside a newly added window fires on the next run.

  • Renaming or deleting a trigger orphans live workflows silently. workflows.trigger_event stores the event as a plain string β€” nothing enforces that it still exists. A workflow whose trigger you deleted stays active, looks perfectly healthy in the list, and simply never fires again. If you rename an event, migrate the column:

    UPDATE workflows SET trigger_event = 'licence.expiring'
     WHERE trigger_event = 'software_licence.expiring';

No schema change is needed for a new trigger β€” workflow_scheduled_emissions is generic, keyed on strings you supply. That's deliberate: adding a trigger should be a code change, not a migration.

Don't forget

Per the project's standing rules: CHANGELOG.local.md, the in-app help (lang/en/workflow.php and lang/pt-BR/workflow.php β€” the i18n fallback is per-key, so a missing translation shows English mid-page and nothing warns you), docs/workflow-scheduled-cron-setup.md, and this page.


Key files

File Role
cron/workflow_scheduled.php Entry point for the expiry triggers (token auth on HTTP, min-interval guard)
includes/workflow_scheduled.php The detectors, the ledger, the fingerprints, the pruner
cron/sla_breach_check.php Emits sla.warning / sla.breached from the existing SLA pass
workflow/includes/engine.php availableTriggers() β€” where a new event is registered
workflow/includes/templates.php The starter recipes
workflow_scheduled_emissions The fire-once ledger (unique key + INSERT IGNORE)

See also

  • Workflows β€” the engine, the canvas, conditions, actions and the execution log.
  • Workflow & Webhook Pitfalls β€” including #9, the ledger bug this feature very nearly shipped with.

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally