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 delivery
id (X-GitHub-Delivery / X-OpsChain-Delivery, else the body digest), the
payload is capped at 1 MiB and must be JSON, and each connection carries its
own per-minute rate limit, answered 429 before any verification.
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.
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. |
| 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) — 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.