Skip to main content
Version: v5.6.1

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 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​

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.
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) — 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.