Skip to content

Business Rules

Business rules are the automation layer in oikapi. They fire at specific lifecycle points and can validate data, calculate fields, run scripts, trigger workflows, send notifications, and call external services.

Every rule has a trigger (when it fires), an optional condition (whether it fires), and one or more actions (what it does).

The rule type is UI categorization metadata that helps organize rules in the builder. It does not change how a rule executes — behavior comes entirely from the trigger and actions.

TypeTypical use
validationBlock operations that don’t meet criteria
calculationAuto-compute field values
field_defaultConditionally set field values
scriptRun custom JavaScript/TypeScript
notificationSend notifications
TriggerWhen it fires
before_validateBefore field validation runs
before_createAfter validation, before INSERT
after_createAfter INSERT, before the response is returned
before_updateBefore UPDATE
after_updateAfter UPDATE
before_deleteBefore a (soft) delete
after_deleteAfter a (soft) delete
on_field_changeWhen a specific field changes
scheduledOn a cron schedule
recurrenceWhen a recurrence rule (RRULE) occurrence fires
deadlineWhen an SLA / deadline threshold is crossed
manualOn-demand via an action button
webhookWhen an inbound webhook arrives
form_submissionAfter a public form submission creates a record
email_inboundWhen inbound email creates or updates a record
http_endpointWhen a custom named API route is called
app_eventWhen another application emits a subscribed event

Rules contain one or more actions that execute when conditions are met. The most common actions are below; advanced installations expose additional purpose-built actions (currency conversion, price resolution, stock movements, alerting, cross-app writes, and more).

Block an operation with an error message:

{
"type": "validate",
"config": {
"condition": "gt(amount, 0)",
"message": "Amount must be positive"
}
}

Every other scoped action below spells its scoping key if; validate accepts either if or condition (they’re synonyms here) — supplying both with conflicting values is a configuration error.

Set a field value, optionally only when an if condition holds:

{
"type": "set_field",
"config": {
"field": "status",
"value": "approved",
"if": "gte(amount, 10000)"
}
}

Compute a field from a formula:

{
"type": "calculate",
"config": {
"target_field": "total",
"formula": "quantity * unit_price",
"precision": 2
}
}

Copy a value from a related record:

{
"type": "copy_field",
"config": {
"from_table": "customers",
"from_field": "credit_limit",
"to_field": "customer_credit_limit",
"lookup_field": "customer_id"
}
}

Run a JavaScript script — either inline or by reference:

{
"type": "execute_script",
"config": {
"script_id": "<script-uuid>"
}
}

You can also supply "script" with inline code instead of script_id.

Send an email or SMS to a recipient:

{
"type": "send_notification",
"config": {
"channel": "email",
"to": "$record.contact_email",
"subject": "Your order $record.id was received",
"body": "Thanks for your order."
}
}

Field references in to, subject, and body are interpolated from the record. See Notifications for the richer scripting API.

Start an approval workflow. Only allowed in after_* triggers (workflows run asynchronously):

{
"type": "trigger_workflow",
"config": {
"workflow": "expense_approval"
}
}

Block a write when a record enters a gated state carrying an over-threshold amount that has not been approved. This is a synchronous gate — it blocks the write, it does not route an approval request:

{
"type": "require_approval",
"config": {
"gate_field": "status",
"gate_values": ["sent", "accepted"],
"amount_field": "discount_percent",
"threshold": 20,
"approved_field": "discount_approved",
"message": "Discounts over {threshold}% require approval"
}
}

Create a routed approval request after the write commits, without blocking it. Approvers are resolved by role, user, or an explicit list and resolve under any/all/majority mode:

{
"type": "request_approval",
"config": {
"approver_type": "role",
"approver_role": "manager",
"mode": "any",
"title": "High-value order needs approval"
}
}

Rules can have a condition using the same filter syntax as queries. The rule’s actions run only when the condition evaluates true:

{
"condition": "and(eq(status, 'submitted'), gt(amount, 5000))",
"actions": [...]
}

A rule’s top-level condition is honored end-to-end, including for rules installed as part of a package — it round-trips through the install payload and is evaluated the same as a directly-created rule’s condition. Scope a package rule with its top-level condition rather than re-deriving the same predicate inside every action.

Monitor specific field changes with fine-grained control:

{
"trigger": "on_field_change",
"trigger_config": {
"watched_fields": ["status"],
"change_type": "to",
"to_value": "approved"
}
}

Change types: any, to, from, from_to, comparison. The comparison type supports increase_by, decrease_by, gt, lt, gte, lte.

Rules can run in the background via the job queue:

{
"async": true,
"async_priority": 100,
"async_delay_seconds": 60
}

Failed async rules retry with exponential backoff.

Run rules on a cron schedule:

{
"trigger": "scheduled",
"trigger_config": {
"cron": "0 2 * * *",
"timezone": "America/Denver"
}
}

Scheduled rules have no single-record context — they run application-wide.

A rule must be active to execute. The lifecycle states are:

StatusMeaning
draftEditable, not executing
pending_reviewAwaiting approval
activeExecuting on its table
disabledManually disabled
  • Permission mode: user (default, runs with the user’s permissions), creator (runs with the rule creator’s permissions), or elevated (admin-only, bypasses permission checks).
  • Application boundaries: Rules can only access tables in their own application plus system tables. To access tables in another application, an active data_access integration must exist between the two applications.
  • Loop protection: Rule chains are bounded by a maximum execution depth, and each script is bounded by a maximum number of cross-table operations.

Cross-app table access is controlled by data_access integrations between applications — not by a field on the rule itself. When a rule tries to read or write a table in another application, the platform checks for an active data_access integration from the rule’s application to the target application. If no such integration exists, the operation is denied.

Business rules are created and managed through the system tables API:

POST /api/apps/system/tables/business_rules/records
{
"name": "Validate positive amount",
"table_id": "<table-uuid>",
"application": "my-app",
"scope": "table",
"trigger": "before_create",
"rule_type": "validation",
"active": true,
"actions": [
{
"type": "validate",
"config": {
"condition": "gt(amount, 0)",
"message": "Amount must be positive"
}
}
]
}