Skip to content

Add business rules and expressions

What you're doing

Attaching declarative logic to an entity's lifecycle: reject an invalid save, compute a field, kick off a job or workflow. One rule = one file under spk-assembly/metadata/rules/, evaluated by the Rule Engine at a lifecycle point.

When each action type fires

triggerEventfirestypical use
BEFORE_CREATE / BEFORE_UPDATEbefore the writeREJECT (validation), SET_VALUE (computed/defaulted field)
AFTER_CREATEafter insertEXECUTE_SERVICE (register a job on first row), START_WORKFLOW
AFTER_UPDATEafter updateSTART_WORKFLOW on a status transition
AFTER_DELETEafter deletecleanup fan-out

The complete example — a validation + a computed field

spk-assembly/metadata/rules/oeq_checkout_dates.json (a standalone example, real file):

json
{
  "entityType": "oeq_checkout",
  "name": "oeq_checkout_dates",
  "description": "Due date must be on or after the checkout date.",
  "triggerEvent": "BEFORE_CREATE",
  "conditions": { "field": "due_date", "op": "lt", "value": "${record.checkout_date}" },
  "actions": [
    { "type": "REJECT", "message": "Due date cannot be before the checkout date." }
  ],
  "priority": 10,
  "active": true
}

In a metadata/rules/*.json file conditions and actions may be authored as real JSON (as above) or as pre-stringified JSON (as the workflow-trigger rule does). Both install. The stringified form matches what the live API stores and returns.

Line by line

  • entityType — the entity name this rule fires against.

  • triggerEvent — one of the six lifecycle points above.

  • conditions — a condition tree. A leaf is { field, op, value }. Compose with { "all": [...] }, { "any": [...] }, { "not": {...} }.

    • operators: eq, neq, gt, gte, lt, lte, contains, in, is_null, is_not_null. is_null / is_not_null take no value.
    • ${record.<field>} in a value reads another field of the same row.
    • **${record.<field>__previous}** (in an AFTER_UPDATE` rule) is the value before the update.
  • actions — at least one:

    typefieldseffect
    REJECTmessageabort the write, surface message to the caller
    SET_VALUEfield, valueassign a field before the write
    EXECUTE_SERVICEservice (a registered handler bean)run a named platform service
    START_WORKFLOWworkflowName, config, callbackUrlsee Add an approval workflow
  • priority — lower first. Put REJECT/SET_VALUE validation rules at 10, workflow triggers at 20.

Computed fields — two ways

  1. A SET_VALUE rule (above) — imperative, runs at a lifecycle point.

  2. A formula field on the entity — declarative, computed on every read, no physical column:

    json
    { "fieldName": "days_out", "label": "\"Days Out\"", "dataType": "formula",
      "flags": "{\"formula\":\"daysBetween(checkout_date, now())\"}" }

    The formula language is the platform ExpressionEvaluator — whitelisted functions only (daysBetween, now, coalesce, arithmetic, comparisons). No ternary, no in, no ^. The same evaluator powers compliance-sweep conditions (see Add a compliance-sweep job).

Registering a job on the first config row

The pattern jobs use to auto-register themselves — an unconditional AFTER_CREATE EXECUTE_SERVICE:

json
{
  "entityType": "entity_aggregation_config",
  "name": "ensure_aggregation_sweep_job_registered",
  "triggerEvent": "AFTER_CREATE",
  "conditions": null,
  "actions": "[{\"type\": \"EXECUTE_SERVICE\", \"service\": \"ensureEntityAggregationSweepJobRegistered\"}]",
  "priority": 10,
  "active": true
}

conditions: null means "always". See Seed data on install.

Ground yourself first

bash
erp schema pull entity-rule-definition
erp examples patterns --kind entities

How to verify it worked

After publishing:

bash
erp api get "/api/v1/entity-rules?entityType=oeq_checkout"

lists your rule with "active": true. Then trigger it:

bash
erp api post "/api/v1/entities/oeq_checkout/records" \
  --body '{"checkout_number":"CO-9","equipment_id":1,"employee_id":1,"checkout_date":"2026-09-10","due_date":"2026-09-01","status":"DRAFT"}'
json
{ "error": "Due date cannot be before the checkout date." }

Common mistakes

SymptomCauseFix
a != null / !=null comparison never matchesaction-engine quirkuse is_not_null
rule fires on every update, not just the transitionno __previous guardadd { "field": "status__previous", "op": "neq", "value": "..." }
SET_VALUE doesn't stickused AFTER_* (write already happened)use BEFORE_CREATE / BEFORE_UPDATE
formula field errorsused ternary / in / unsupported functionrewrite with whitelisted functions only
install rejects the rule fileactions is a bare object, not an array/stringactions is an array (or a stringified array)