Workflows & Notifications
Workflows orchestrate multi-step processes in your application. They can be triggered by entity events, manual actions from the UI, or webhooks. Use domain commands for guarded state transitions, then compose those commands with queries, notifications, and external effects in a workflow.
Defining a Workflow
Section titled “Defining a Workflow”[workflow.SetIssueStatus]description = "Update issue status and write an audit event."
[workflow.SetIssueStatus.trigger]manual = true
[[workflow.SetIssueStatus.steps]]type = "command"command = "SetIssueStatus"recordId = "{{ variables.data.record.id }}"input = { status = "{{ variables.data.payload.status }}" }Trigger Types
Section titled “Trigger Types”Manual Triggers
Section titled “Manual Triggers”Triggered from the UI via action bar buttons on detail pages:
[workflow.ApproveRequest.trigger]manual = trueWire it to a page action bar:
[[page."/issues/:id".actionBar.actions]]label = "Approve"workflow = "ApproveRequest"style = "primary"Entity Event Triggers
Section titled “Entity Event Triggers”Fire when records are created, updated, or deleted:
[workflow.NotifyOnDone.trigger]entity = "Issue"event = "update"
[workflow.NotifyOnDone.trigger.condition]"after.status" = "done""before.status" = { "$ne" = "done" }The condition uses before.* and after.* to compare the record’s state before and after the change. The $ne operator means “not equal”.
Entity Event Loop Protection
Section titled “Entity Event Loop Protection”A workflow can write an entity that triggers another workflow. Zebric carries propagation metadata through these nested entity events and applies two automatic safeguards:
- Cycle break: Zebric records the workflows already visited in the current propagation chain. If a matching workflow appears in that path again, Zebric does not enqueue it. This stops direct cycles such as
A → Aand indirect cycles such asA → B → A. - Depth break: Zebric allows a maximum nested entity-event propagation depth of
5. If a chain would continue beyond that depth, Zebric skips the next trigger even when no workflow name has repeated.
The workflow step that produced the entity change has already completed when either safeguard is evaluated. The safeguard prevents the next workflow from being enqueued; it does not roll back the write that reached the limit.
Skipped triggers produce a runtime warning. Cycle warnings include the entity, event, workflow name, and workflow path. Depth warnings include the current and maximum depth. Use these warnings to correct the workflow design rather than relying on the limits as normal control flow.
You should still make entity-triggered workflows converge naturally. Use before.* and after.* conditions so a transition runs only when the relevant value actually changes:
[workflow.MarkIssueDone.trigger]entity = "Issue"event = "update"
[workflow.MarkIssueDone.trigger.condition]"after.status" = "done""before.status" = { "$ne" = "done" }Webhook Triggers
Section titled “Webhook Triggers”Receive events from external systems:
[workflow.HandleSlackCommand.trigger]webhook = "/webhooks/slack"webhookSecretEnv = "SLACK_WORKFLOW_WEBHOOK_SECRET"Inbound workflow webhooks require authentication. Set webhookSecretEnv to the
environment variable containing the webhook secret, or use the default
ZEBRIC_WEBHOOK_SECRET. Send the secret as Authorization: Bearer <secret>, or
send X-Zebric-Webhook-Timestamp plus X-Zebric-Webhook-Signature, where the
signature is sha256= followed by the hex HMAC-SHA256 of
<unix timestamp>.<raw request body>. Signed requests expire after five minutes.
Step Types
Section titled “Step Types”Command Steps
Section titled “Command Steps”Execute a domain command using the workflow’s actor and context:
[[workflow.MyWorkflow.steps]]type = "command"command = "CloseIssue"recordId = "{{ variables.data.record.id }}"input = { resolution = "{{ variables.data.payload.resolution }}" }Command steps enforce typed input, policy, state availability, and command-only field protection. Prefer them over query updates for lifecycle state. See Domain Commands.
Query Steps
Section titled “Query Steps”Perform database operations:
[[workflow.MyWorkflow.steps]]type = "query"entity = "Issue"action = "update"[workflow.MyWorkflow.steps.where]id = "{{ variables.data.record.id }}"[workflow.MyWorkflow.steps.data]summary = "{{ variables.data.payload.summary }}"Actions: create, update, delete, findOne. Query steps are appropriate for ordinary data operations. A query step cannot write a field marked write = "command-only".
Transactions
Section titled “Transactions”Set transactional = true when database changes in a workflow must commit or roll back together:
[workflow.CloseAndArchive]trigger = { manual = true }transactional = true
[[workflow.CloseAndArchive.steps]]type = "command"command = "CloseIssue"recordId = "{{ variables.data.record.id }}"Command and query mutations participate in the active database transaction. Do not place external service, webhook, or notification effects inside a transactional workflow because those effects cannot be rolled back with the database.
Condition Steps
Section titled “Condition Steps”Branch based on previous step results:
[[workflow.MyWorkflow.steps]]type = "condition"if = { "steps.0.result.id" = { "$exists" = true } }then = [ { type = "command", command = "RequestIssueApproval", recordId = "{{ variables.data.record.id }}" }]else = [ { type = "query", entity = "AuditEvent", action = "create", data = { issueId = "{{ variables.data.record.id }}" } }]Notify Steps
Section titled “Notify Steps”Send notifications via configured adapters:
[[workflow.MyWorkflow.steps]]type = "notify"adapter = "slack_dispatch"body = "Issue resolved: {{ trigger.after.title }}"[workflow.MyWorkflow.steps.metadata]mrkdwn = trueWebhook Steps
Section titled “Webhook Steps”Make HTTP requests to external services:
[[workflow.MyWorkflow.steps]]type = "webhook"url = "https://api.example.com/events"method = "POST"body = { issueId = "{{ variables.data.record.id }}" }Template Variables
Section titled “Template Variables”Workflow steps can reference data using {{ }} template syntax:
| Variable | Description |
|---|---|
variables.data.record | The current record |
variables.data.payload | Data sent with the action |
trigger.before | Record state before update (event triggers) |
trigger.after | Record state after update (event triggers) |
steps.N.result | Result of step N (zero-indexed) |
Notifications
Section titled “Notifications”Notifications are delivered through adapters. Configure them in the [notifications] section.
Console Adapter
Section titled “Console Adapter”Logs to stdout (useful for development):
[[notifications.adapters]]name = "console"type = "console"Slack Adapter
Section titled “Slack Adapter”Posts messages to Slack channels:
[[notifications.adapters]]name = "slack_ops"type = "slack"[notifications.adapters.config]botTokenEnv = "SLACK_BOT_TOKEN"signingSecretEnv = "SLACK_SIGNING_SECRET"defaultChannel = "#ops"| Config | Description |
|---|---|
botTokenEnv | Environment variable holding the Slack bot token |
signingSecretEnv | Environment variable for webhook signing secret |
defaultChannel | Default channel for messages |
defaultChannelEnv | Env var for default channel |
The Slack adapter also handles inbound webhooks for interactive messages.
Email Adapter
Section titled “Email Adapter”Sends email notifications:
[[notifications.adapters]]name = "email"type = "email"[notifications.adapters.config]from = "noreply@example.com"outboxFile = "./data/email-outbox.log"Setting a Default
Section titled “Setting a Default”[notifications]default = "console"The default adapter is used when a notify step doesn’t specify an adapter.