Skip to main content
Version: v5.7.0

Triggers, firings and chains

triggers

Triggers are event automation: a project-scoped rule that matches events on the stream (opschain docs events) and creates a change or a workflow run in response. Each match is recorded as a firing — the settled outcome, exactly once per (trigger, event). Everything a firing causes shares the matched event's correlation chain, so the whole cascade is inspectable as one unit.

Neighbours: the events stream feeds the engine; maintenance windows (opschain docs maintenance-windows) park firings instead of letting automation run while admins work; the created runs enter the dispatch plane (opschain docs fleet-queue).

The trigger, attribute by attribute​

Triggers live under a project: GET|POST /api/projects/{project_code}/triggers, GET|PATCH|PUT|DELETE …/triggers/{id}, plus …/triggers/{id}/firings, …/triggers/{id}/firings/{firing_id}/discard and …/triggers/{id}/test.

AttributeMeaning
codeThe trigger's name within the project — lowercase letters, numbers and underscores, unique per project, immutable after create.
nameDisplay name; defaults to the code.
descriptionOptional free text.
enabledWhether the engine matches it (default true). A disabled trigger is left alone by sweeps; its already-parked deferred firings settle suppressed at release rather than silently vanishing.
subjectWhat kind of thing the events are about: change, workflow_run, step, artifact or external (artefact is accepted at the edge and stored as artifact).
eventsThe match vocabulary: friendly lifecycle names (created, started, finished, error, cancelled) expanded per subject, and/or namespaced event types matched literally (e.g. deploy:finished, api:changes:error).
scope_pathOptional node-path prefix; unscoped sees the whole estate, scoped only events under its path.
conditionsFlat AND-ed strings event.<path> <op> <value> with ops eq, ne, in (comma-separated set), contains — evaluated against the event document; a malformed or unresolvable condition fails closed. Stored verbatim.
action_typechange or workflow_run.
action_paramsThe create payload. A change trigger must include a parent_path starting with / and an action; a workflow_run trigger a non-empty workflow (its project defaults to the trigger's own project, and an optional version pins a published version — omitted means latest). Values may carry ${{ event.<path> }} templates resolved per firing against the matched event. Other keys pass through to the create.
throttle_per_minutePer-trigger firing throttle (default 60, minimum 1): past it, further matches settle suppressed.
run_asOptional identity the firings execute — and authorise — as, instead of the owning author. Setting or retaining it requires the acting author's explicit act_as trust grant covering the project path, and a resolvable (not disabled, not break-glass) identity; refusals are named 422s. Service accounts (opschain docs cli/admin-service-accounts) are the durable recommendation for anything a team depends on — personal run-as-yourself automations stay first-class. Scheduled activities carry the same field with the same guardrails.
owned_byOptional owning group: maintenance and failure-notification accountability routes to the team, surviving individual departures — the firing-identity warn events carry it for subscription routing. Must name a known group (local or provider snapshot); unknown groups are named 422s. Groups never act: the firing authority is always the executing principal. Scheduled activities carry it too.
created_by / created_at / updated_atAuthorship and timestamps.

The friendly names expand to the api: lifecycle types per subject — e.g. subject change + finished ⇒ api:changes:success; error ⇒ api:changes:error, api:changes:system_error, api:changes:pre_execution; cancelled ⇒ cancel and abort. Subject external has no expansion: it is the custom-event subject, matched by namespaced entries only — including external:webhook:received, the event every verified inbound webhook delivery lands as (see below).

RBAC rides the project's /…/triggers collection path for list/create and the trigger's own …/triggers/<code> rule path for show/update/delete, 404-on-deny. Creating a trigger is gated by the licence feature triggers (opschain docs entitlements). Delete is hard; firings cascade with the row.

The engine​

The engine is a cursor-based consumer of the events stream: every 2 seconds each server sweeps up to 200 events past the stored cursor in (created_at, id) order, matches them against the enabled triggers (of non-deleted nodes), and executes matches through the ordinary create commands. The cursor seeds at the newest event on first boot and the sweep skips any event minted before a trigger's creation — history is never replayed against triggers created later, however far the cursor lags — and the cursor advances with a CAS so a lost race never rewinds it. Exactly-once across the HA topology comes from the firings table's unique (trigger_id, event_id): concurrent sweepers race the insert and the loser skips. A trigger's own fired and deferred events are excluded from matching it, so a trigger cannot ring on its own bell.

A claimed firing crosses these rails, in order:

  1. Licence — a post-grace work stop settles it suppressed with the stop's message.
  2. Chain depth cap (25) — chain depth <n> exceeds the cap (25) — a trigger loop is being damped: settles suppressed and emits warn:triggers:depth_cap.
  3. Throttle — throttled: <n> firings in the last minute reaches the trigger's limit (<n>/minute): settles suppressed.
  4. Templates — an unresolvable ${{ event.<path> }} settles it failed: unresolved reference ${{ event.<path> }}: no such value on the matched event.
  5. Maintenance — a covering active window parks the firing deferred (see opschain docs maintenance-windows).
  6. Identity and RBAC — the firing executes as the run_as identity when set, the owning author otherwise; the identity must resolve (a disabled owner or a refused run_as settles the firing failed and emits warn:triggers:identity, which carries owned_by when the trigger names an owning group, and points at opschain admin users reassign-automations as the repair) and must hold ordinary read+update on the target path: trigger <code> owner <user> is not authorised to create on <path> (or run_as identity). No privileged side door. Scheduled-activity firings resolve their executing identity (run_as, else the creator) through the same directory with the same refusals.
  7. The create — success settles created with the created Change/WorkflowRun id; a create failure settles failed.

Every settle emits api:triggers:fired on the matched event's chain, sourced from the trigger, carrying trigger, firing_id, status, matched_event_id, author and executed_as (the dual-principal audit), plus created_type/created_id or the failure message. Five consecutive failed sweeps emit warn:triggers:engine ("the trigger engine failed several consecutive sweeps — event automation is stalled; check the server log") so a dead matcher is never a silent stall. While a system-quiesce posture is in force the engine's tick does nothing at all (opschain docs platform-halt).

The firing, attribute by attribute​

AttributeMeaning
statuscreated (something was made), suppressed (matched, intentionally not acted on), failed (the action could not be created) or deferred (parked by a maintenance window; the one non-terminal status — it releases when the window ends unless discarded first).
created_type / created_idWhat the firing made (Change/WorkflowRun + id), on the created arm.
errorThe reason otherwise — throttle, depth cap, template or create failure, or a deferral/discard reason.
correlation_idThe chain the firing (and anything it created) belongs to.
executed_asThe identity the firing executed as (the run_as identity when set, the author otherwise).
created_atWhen the match was claimed.

Firings list newest first (opschain triggers firings, opschain docs cli/triggers-firings); the firing resource carries trigger and event relationships, so both ends stay addressable.

Testing, import and export​

  • POST …/triggers/{id}/test (opschain triggers test) dry-runs the trigger against one stored event using the live engine's own match functions — it writes nothing. The report gives the per-gate verdicts (matched, enabled, subject_and_event_match, scope_match, each condition with the value it evaluated against, the resolved action params or the template_error) and the combined would_fire. A missing event answers No event with id <id> exists — note that a correlation id is not an event id; the events listing shows each event's own id.
  • opschain triggers export / import move a trigger as a nested when/then interchange YAML through the ordinary authorised create/update path — backup and change control, never sourcing. See opschain docs cli/triggers-export and opschain docs cli/triggers-import.
  • opschain triggers enable / disable flip enabled; opschain triggers discard prunes one maintenance-deferred firing (opschain docs cli/triggers-discard).

Chains​

Every event, change and workflow run carries a correlation_id; a trigger-created run inherits the matched event's chain with chain_depth + 1 and initiated_by_type: trigger. A row without a stored correlation id is its own chain root at read time.

  • GET /api/chains/{correlation_id} — the full cascade, chronological: each hop typed (event, change, workflow_run) with its summary, node path, cause (caused_by_event_id, initiated_by_type/_id), depth, actor and — where applicable — the spawning workflow step/stage, template version, and the fired hops' author/executed_as. Hops are RBAC-filtered per viewer: an unreadable hop renders as a redacted stub (kind, depth, timestamp only), so the chain's shape survives without leaking another project's detail.
  • GET /api/chains — the chain explorer index: chain summaries (root, first/last activity, member counts, failure marker, involved paths) inside a required time window (since=<N>m|h|d or filter[created_at_gteq]=<RFC3339>, clamped by the stored chains_max_window_days global setting, default 90 days), optionally narrowed by node prefix, origin (user, schedule, trigger, api) and has_failures, keyset-paginated on (last_activity_at, correlation_id) descending via meta.next_after.
  • GET /api/chains/{correlation_id}/triage — the one triage digest the GUI and opschain chains triage share: lifecycle events fold into their objects, each failing hop is classified (error, system_error, aborted, cancelled, pre_execution (never started — infrastructure, git or secrets), orphan event failures, or stalled — a non-terminal member with no dispatch-plane activity after the chain has been quiet for 2 minutes) with its log tail (lines, default 40, max 200) and links; when every failure executed on one runner, the digest attributes it and its group — suspect the runner before the actions.

CLI: opschain chains show and opschain chains triage (opschain docs cli/chains, opschain docs cli/chains-show, opschain docs cli/chains-triage).

The inbound-webhook source​

External systems fire triggers through signed HTTP calls: an administrator registers an inbound webhook connection (opschain docs cli/admin-inbound-webhooks) and the server serves POST /api/hooks/<code> — unauthenticated except the HMAC-SHA256 signature, which is the authentication. The endpoint accepts the GitHub-style X-Hub-Signature-256: sha256=<hex> shape (HMAC over the raw body) and the generic X-OpsChain-Signature: v1=<hex> shape (HMAC over <timestamp>.<body> with X-OpsChain-Timestamp, enforced against the connection's replay window). Deliveries dedup exactly-once on the digest of the verified signature — a captured delivery replayed under a fresh delivery id is still refused 409 — while the delivery id (X-GitHub-Delivery / X-OpsChain-Delivery, else the body digest) names the delivery in the event; the payload is capped at 1 MiB and must be JSON, each connection carries its own per-minute rate limit, answered 429 before any verification, and deliveries naming an unknown code are budgeted per client address.

A verified delivery lands as one external:webhook:received event on /platform/inbound-webhooks/<code>, carrying data.connection (the connection code), data.event (the envelope event type from X-GitHub-Event / X-OpsChain-Event), data.delivery_id and the raw payload under data.payload. A trigger binds to a connection with subject external, the namespaced event name, and conditions over that document:

subject: external
events: [external:webhook:received]
conditions: ["event.data.connection eq ghmain", "event.data.event eq push"]

The payload rides the ordinary templating idiom into the created run — ${{ event.data.payload.ref }} in the action params (inputs included) resolves against the delivered document.

The service-account rule: every firing a webhook delivery causes executes — and is authorization-checked — as the connection's service account, never as the trigger's owner or run_as identity. No inbound call carries ambient authority: the connection's administrator chose exactly which identity external systems may act as, and a connection whose service account is missing, disabled or unauthorised settles its firings failed by name (with warn:triggers:identity), never silently and never as someone else. Refused deliveries — bad signature, stale timestamp, replayed delivery id, disabled or unknown connection — answer a typed 4xx AND a warn:webhooks:refused event; a webhook refusal is never a silent drop and never a 200.

The git ref-move source​

A push to any Git host — GitHub, GitLab, Bitbucket, Azure DevOps, an on-premises server, an SSH bare repository — reaches triggers without a per-host webhook: the fetch the platform already performs on every Git remote (opschain docs resources/git_remote, "The periodic fetch") records info:git_remote:ref_moved for every watched ref that moved, carrying the SHAs the platform observed, never the SHAs a sender claimed. Watched refs are the union of the remote's default branch (unless the remote's watch_default_branch is off), every ref a floating template version follows, and every ref an enabled trigger names through its conditions — so writing the trigger is what starts the watch, from the next fetch. The event lands on the remote's project path with data.remote, data.ref (the full name, refs/heads/main), data.kind (branch | tag), data.change (fast_forward | forced | created | deleted), data.old_sha, data.new_sha, data.commits and data.fetch.

subject: external
events: [info:git_remote:ref_moved]
scope_path: /projects/demoenterprise
conditions:
- event.data.remote eq demo-repo
- event.data.ref eq refs/heads/main
- event.data.change in fast_forward,created
action_type: workflow_run
action_params:
workflow: deploy-dev
inputs:
sha: ${{ event.data.new_sha }}
run_as: svc-deployer
owned_by: platform-team

${{ event.data.new_sha }} is also what git.content('demo-repo', event.data.new_sha, 'deploy/manifest.yaml') anchors a repository read to inside the created run (opschain docs expressions, "content() and list()"). event.data.change is the force-push lever: the example above does not fire on a rewritten history (forced), and a trigger that should must admit it by name. A forced move always records warn:git_remote:forced_push beside the ref_moved event, listener or not. The conditions a trigger may name a ref with are event.data.ref eq, event.data.ref in a,b, event.data.ref contains <text> and event.data.kind eq branch|tag; a trigger naming the event with no ref condition watches every branch and tag of the remotes it names (or of every remote in its scope). Moves of one ref observed inside the remote's fetch interval coalesce into one event (the first old_sha, the latest new_sha, commits summed), so ten commits pushed in a minute are one deployable state; a forced move or a deletion is never coalesced. A webhook from the host is a fetch hint, not the trigger source: a GitHub push delivery or a generic inbound delivery to a connection that names the remote schedules the fetch, and the ref_moved event the fetch records seconds later is what the trigger matches — the delivery's own external:webhook:received still lands as the delivery record, so triggers written against event.data.github.* keep working.

Levers, and who owns them​

LeverAuthorityEffect
Trigger CRUD, enable/disableUpdate/delete on the trigger's rule path (…/triggers/<code>); create on the project's /…/triggers pathWhat automation exists and whether it matches.
throttle_per_minuteThe trigger definitionCaps the firing rate; excess matches settle suppressed, visibly.
run_asThe trigger definition, guarded by the author's act_as trust grant (the agent platform's grant machinery)Which identity firings execute and authorise as; re-checked on every update that retains it.
owned_byThe trigger definition; must name a known groupWhich team carries the automation's maintenance and failure-notification accountability.
Chain depth cap (25), sweep cadence (2s), batch (200)Built-in engine constantsLoop damping and matcher latency; no config.yaml key tunes them.
chains_max_window_daysDB-authoritative global setting (opschain docs settings)The chain index's maximum query window (default 90 days).
Maintenance windowsThe window node's …/maintenance rule path (opschain docs maintenance-windows)Park firings instead of creating; release on window end.
Inbound webhook connections (secret, service account, replay window, rate limit, enabled)The admin plane (opschain admin inbound-webhooks)Which external systems may land external:webhook:received events, and which identity their firings execute as.
watch_default_branch and the fetch interval on a Git remoteThe project's …/git_remotes rule path (opschain git-remotes update)Whether the remote's default branch records info:git_remote:ref_moved with nothing naming it, and the window ref moves coalesce in.
LicenceThe installed licence (opschain docs entitlements)Feature triggers gates creation; a work-stop suppresses firings with the named reason.

Events vocabulary​

Emitted by this component, verbatim:

  • api:triggers:fired — one per settled firing (status and audit in the data), on the matched event's chain.
  • info:triggers:deferred — a firing parked under a maintenance window.
  • warn:triggers:depth_cap — a firing suppressed at the chain-depth cap (a trigger loop being damped).
  • warn:triggers:deferral_expired — a released deferral past its window's declared until, suppressed; lands on the window's node.
  • warn:triggers:identity — a firing's executing identity refused resolution (disabled owner, refused run_as); carries owned_by when the trigger names an owning group.
  • warn:triggers:engine — the engine failed several consecutive sweeps; event automation is stalled.

The inbound-webhook endpoint beside it emits external:webhook:received (one per verified delivery) and warn:webhooks:refused (one per named refusal); the Git remote fetch emits info:git_remote:ref_moved (one per watched ref that moved) and warn:git_remote:forced_push (one per rewritten history) — see opschain docs events.

Failure modes​

  • "Why didn't my trigger fire?" — run opschain triggers test against the event: the per-gate report answers without waiting for the next match, and cannot disagree with a real sweep (same code).
  • A trigger loop does not melt the estate: the depth cap suppresses at depth 25 and the per-trigger throttle bounds the rate — both as visible suppressed firings, never silent drops.
  • History is never replayed: the cursor seeds at the newest event on first boot and the sweep never matches a trigger against an event minted before that trigger was created, so a new trigger only sees events from its creation on; a firing is unique per (trigger, event), so overlapping HA sweeps cannot double-create. The dry-run test endpoint is the deliberate exception — it evaluates any named historical event.
  • A deleted node's triggers sleep (the sweep loads only triggers of non-deleted nodes) until the node is restored; deleting a trigger cascade-deletes its firings.
  • A wedged engine is loud — warn:triggers:engine after five failed sweeps; the streak re-arms so a persistent wedge keeps reporting.
  • A crash mid-fire leaves the firing pending (claimed but unsettled); the maintenance release path deliberately re-enters at the same point, so a crash mid-release looks exactly like a crash mid-fire.
  • opschain docs events — the stream, the type grammar and the full type registry.
  • opschain docs maintenance-windows — deferral, release, expiry and discard.
  • opschain docs cli/triggers — the CLI family, verb by verb.
  • opschain docs cli/chains — chain inspection from the terminal.