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.
| Attribute | Meaning |
|---|---|
code | The trigger's name within the project — lowercase letters, numbers and underscores, unique per project, immutable after create. |
name | Display name; defaults to the code. |
description | Optional free text. |
enabled | Whether 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. |
subject | What kind of thing the events are about: change, workflow_run, step, artifact or external (artefact is accepted at the edge and stored as artifact). |
events | The 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_path | Optional node-path prefix; unscoped sees the whole estate, scoped only events under its path. |
conditions | Flat 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_type | change or workflow_run. |
action_params | The 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_minute | Per-trigger firing throttle (default 60, minimum 1): past it, further matches settle suppressed. |
run_as | Optional 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_by | Optional 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_at | Authorship 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:
- Licence — a post-grace work stop settles it
suppressedwith the stop's message. - Chain depth cap (25) —
chain depth <n> exceeds the cap (25) — a trigger loop is being damped: settlessuppressedand emitswarn:triggers:depth_cap. - Throttle —
throttled: <n> firings in the last minute reaches the trigger's limit (<n>/minute): settlessuppressed. - Templates — an unresolvable
${{ event.<path> }}settles itfailed:unresolved reference ${{ event.<path> }}: no such value on the matched event. - Maintenance — a covering active window parks the firing
deferred(seeopschain docs maintenance-windows). - Identity and RBAC — the firing executes as the
run_asidentity when set, the owning author otherwise; the identity must resolve (a disabled owner or a refused run_as settles the firingfailedand emitswarn:triggers:identity, which carriesowned_bywhen the trigger names an owning group, and points atopschain admin users reassign-automationsas the repair) and must hold ordinary read+update on the target path:trigger <code> owner <user> is not authorised to create on <path>(orrun_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. - The create — success settles
createdwith the createdChange/WorkflowRunid; a create failure settlesfailed.
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
| Attribute | Meaning |
|---|---|
status | created (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_id | What the firing made (Change/WorkflowRun + id), on the created arm. |
error | The reason otherwise — throttle, depth cap, template or create failure, or a deferral/discard reason. |
correlation_id | The chain the firing (and anything it created) belongs to. |
executed_as | The identity the firing executed as (the run_as identity when set, the author otherwise). |
created_at | When 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 thetemplate_error) and the combinedwould_fire. A missing event answersNo 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/importmove a trigger as a nested when/then interchange YAML through the ordinary authorised create/update path — backup and change control, never sourcing. Seeopschain docs cli/triggers-exportandopschain docs cli/triggers-import.opschain triggers enable/disableflipenabled;opschain triggers discardprunes 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|dorfilter[created_at_gteq]=<RFC3339>, clamped by the storedchains_max_window_daysglobal setting, default 90 days), optionally narrowed bynodeprefix,origin(user,schedule,trigger,api) andhas_failures, keyset-paginated on (last_activity_at, correlation_id) descending viameta.next_after.GET /api/chains/{correlation_id}/triage— the one triage digest the GUI andopschain chains triageshare: lifecycle events fold into their objects, each failing hop is classified (error,system_error,aborted,cancelled,pre_execution (never started — infrastructure, git or secrets), orphaneventfailures, orstalled— 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
| Lever | Authority | Effect |
|---|---|---|
| Trigger CRUD, enable/disable | Update/delete on the trigger's rule path (…/triggers/<code>); create on the project's /…/triggers path | What automation exists and whether it matches. |
throttle_per_minute | The trigger definition | Caps the firing rate; excess matches settle suppressed, visibly. |
run_as | The 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_by | The trigger definition; must name a known group | Which team carries the automation's maintenance and failure-notification accountability. |
| Chain depth cap (25), sweep cadence (2s), batch (200) | Built-in engine constants | Loop damping and matcher latency; no config.yaml key tunes them. |
chains_max_window_days | DB-authoritative global setting (opschain docs settings) | The chain index's maximum query window (default 90 days). |
| Maintenance windows | The 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 remote | The 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. |
| Licence | The 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 declareduntil, suppressed; lands on the window's node.warn:triggers:identity— a firing's executing identity refused resolution (disabled owner, refused run_as); carriesowned_bywhen 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 testagainst 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
suppressedfirings, 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:engineafter 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.
Related pages
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.