Appearance
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
triggerEvent | fires | typical use |
|---|---|---|
BEFORE_CREATE / BEFORE_UPDATE | before the write | REJECT (validation), SET_VALUE (computed/defaulted field) |
AFTER_CREATE | after insert | EXECUTE_SERVICE (register a job on first row), START_WORKFLOW |
AFTER_UPDATE | after update | START_WORKFLOW on a status transition |
AFTER_DELETE | after delete | cleanup 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/*.jsonfileconditionsandactionsmay 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 entitynamethis 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_nulltake novalue. ${record.<field>}in avaluereads another field of the same row.- **
${record.<field>__previous}** (in anAFTER_UPDATE` rule) is the value before the update.
- operators:
actions— at least one:typefields effect REJECTmessageabort the write, surface messageto the callerSET_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. PutREJECT/SET_VALUEvalidation rules at10, workflow triggers at20.
Computed fields — two ways
A
SET_VALUErule (above) — imperative, runs at a lifecycle point.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, noin, 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 entitiesHow 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
| Symptom | Cause | Fix |
|---|---|---|
a != null / !=null comparison never matches | action-engine quirk | use is_not_null |
| rule fires on every update, not just the transition | no __previous guard | add { "field": "status__previous", "op": "neq", "value": "..." } |
SET_VALUE doesn't stick | used AFTER_* (write already happened) | use BEFORE_CREATE / BEFORE_UPDATE |
| formula field errors | used ternary / in / unsupported function | rewrite with whitelisted functions only |
| install rejects the rule file | actions is a bare object, not an array/string | actions is an array (or a stringified array) |