Declarative glue
Beyond the model artefacts, the intent declares glue: common integrations and activities that would otherwise be hand-written code. The abstraction is one line:
glue =
on <event>thendo <action>, with action parameters bound by resolver paths.
Three axes:
- Event - an entity
onCreate/onUpdate/onDelete(with an optionalwhen:guard), a process step reached or completed, a schedule (cron), or an inbound arrival (a webhook, a message, a dropped file). - Action - notify (email), call out (HTTP), emit a message (queue/topic), ingest into an entity, recompute a counter, start a process.
- Binding - the resolver path grammar (
customer.name,member.email): one-hop relation walks off the triggering entity, validated at parse time exactly like a decision'sthen/else.
Glue is generated annotated client-Java
Unlike the model generators (which emit .edm / .bpmn / .form / ...), each glue activity is generated as an annotated client-Java class against the SDK (org.eclipse.dirigible.sdk.*) under gen/events - @Listener + MessageHandler, @Scheduled + JobHandler, @Controller + @Post. The annotated class is the artefact: engine-java synchronises and runs it, it is deterministic and regenerated with the app, and it is replaceable via a custom/ override.
This is a deliberate exception to "never emit code from a generator" - because client-Java is now the platform's primary runtime and TypeScript is being deprecated. The line still held: no hand-written business logic in gen/. The moment an action needs real logic it becomes a script step or a custom/ hook, never more intent syntax.
The author-facing fields are translated to Java by shared support classes (EventBinding, NotificationSupport, ScheduleSupport, the typed Criteria query API), emitted into <intent>.glue, and rendered by the template-application-events-java templates. gen/events is a sibling of gen/<model>, so it survives the per-model regeneration wipe.
Event-key gotcha
An event-binding key is event:, never on: - YAML 1.1 resolves a bare on (also off / yes / no) to the boolean true, so an on: key is silently swallowed. An action key is do:.
The event axis - lifecycle and process-step events
A glue entry that reacts (notifications, integrations, outbound departures, an event-driven generates create-from) declares exactly one event:, on one of two axes:
| Axis | Shape | Fires when |
|---|---|---|
| entity lifecycle | { onCreate|onUpdate|onDelete: <Entity> } | a record is created / updated / deleted |
| entity enrichment | { onPhase: <Entity>, phase: <name> } | a listener announces a declared phase of the record |
| process step | { onStepReached|onStepCompleted: { process, step } } | a running process arrives at that step / has just finished it |
processes:
- name: LoanApproval
trigger: { onCreate: Loan }
steps:
- { name: librarianReview, kind: userTask, args: { assignee: librarian, next: activate } }
- { name: activate, kind: serviceTask, args: { setField: status, value: ACTIVE } }
notifications:
# "when the review task becomes available, tell the member's branch manager"
- name: reviewPending
event: { onStepReached: { process: LoanApproval, step: librarianReview } }
to: member.branch.managerEmail
subject: "Loan {id} is waiting for review"
body: "A librarian must approve it."
integrations:
# "when the loan has been activated, tell the partner system"
- name: pushActivation
event: { onStepCompleted: { process: LoanApproval, step: activate } }
method: POST
url: "@config:PARTNER_URL"A step event is an event about the record the process runs on - the process's trigger entity - so every action parameter reads exactly as it does for a lifecycle event: the same to: recipient rule, the same {placeholder} interpolation, the same when: guard, the same forwarded body. That is not a coincidence: the generator inserts a small JavaDelegate (<Process><Step>Reached/Completed, the stepEvents glue collection) at the step's boundary, which loads the record by the id in the process context and publishes it on the entity's own topic plus a step suffix - <project>-<perspective>-<Entity>-step-<process>-<step>-reached. The notification / integration listener binds to that topic and consumes an ordinary entity payload; nothing in the action layer knows which axis fired it.
What is rejected at Generate
An undeclared process or step; a step that occupies no observable moment (only a userTask or a serviceTask does - not a decision, a wait or the end); a process with no trigger, since there is then no record the event could be about.
An event-driven create-from binds to the same axis, with one narrowing of its own: the process must run on the create-from's from: entity, since the step event is about the record its process runs on and that record is the one the create-from reads. It also adds its own onTransition lifecycle event, and a mode: that decides whether the trigger mints one target per source or appends one per delivered event.
Delivery is at-least-once on both axes: the record is published after commit, not transactionally with the write, so a redelivery re-notifies, re-forwards, and (under mode: append) appends a second row.
onStepReached fires the moment the execution arrives - for a user task, when it becomes available in the Inbox. onStepCompleted fires after the step finished and after its writes are persisted (the task form's edits via the writer delegate, a setField), and the publish itself is deferred to after commit, so a consumer that re-loads the record never observes it stale. Any number of entries may observe the same moment: the emitter is generated once and publishes once. A branch that jumps back into an observed step re-enters it, so its onStepReached observers fire again.
notifications
Email on an event of the axis above.
notifications:
- name: orderUpdated
event: { onUpdate: Order }
channel: email
to: ops@example.com
subject: "Order {id} for {customer.name}, total {total}"
body: "The order changed."Generates a gen/events/<Name>Notification.java @Listener using sdk.mail.Mail, bound to the entity's create / -updated / -deleted topic. to and {placeholder} resolve a literal, a direct field, or a one-hop relation.field of a to-one relation (the listener loads the related entity once by FK id). when: supports a single field ==|!= literal guard. Multi-hop paths (a.b.c) are the remaining gap; the parser rejects them with a clear message.
The notify block - and attach: print, sending the document itself
to / subject / body is one reusable notify block, not a shape peculiar to notifications. The same block is authored at every place an intent can act on a record:
| Where | The record it is about | Generated as |
|---|---|---|
notifications[] | the event record | <Name>Notification.java (@Listener) |
schedules[].notify | each matched row | <Name>Job.java (@Scheduled) |
transitions[].notify | the transitioned record | inside <Name>Transition.java, after the flip |
a serviceTask's args.notify | the process's trigger record | <Process><Step>Send.java, a JavaDelegate the BPMN binds |
Add attach: print and the message carries the record's own document: the generated <Entity>PrintFeeder assembles the {document, items} payload through the repositories and sdk.print.Print renders it to PDF server-side - the same two steps the snapshot delegate takes - and the result rides along as an application/pdf part. This is the declarative form of the most common outbound action a business document has: the invoice to its customer, the payslip to its employee, a dunning reminder carrying the invoice it is about.
notify:
to: Customer.email # literal / direct field / one-hop relation.field
subject: "Invoice {number}" # {field} and {relation.field} interpolation
body: "Dear {Customer.name}, please find invoice {number} attached."
attach: print # render THIS record's .print template and attach it
language: bg # optional FIXED print-template language
# or per record: languageFrom: Customer.locale (a one-hop relation.field holding the code)attach is print - the record the block is about - or, inside a fan-out, recordPrint. With print the entity must be a document (a header with a line-items child) - that is what has a .print template and a generated feeder to fill it. Attaching the print of a plain entity is a parse-time error, not a silent plain-text mail. The attachment is named after the document's number: field when it has one (INV0000042.pdf), else <Entity> <id>.pdf. The render language: language: fixes it; languageFrom: <relation>.<field> reads it per record off a one-hop to-one path (the customer decides the language their invoice arrives in) - the two are mutually exclusive. It selects the print template and the language the attached document's own data is read in (the issued copy and its language); it does not govern the mail's subject and body, which are the covering message rather than the document. Absent both, the render uses the first entry of the tenant's application language set (DIRIGIBLE_APPLICATION_LANGUAGES) at send time; a blank languageFrom value falls back the same way. The sender address comes from DIRIGIBLE_MAIL_SENDER; delivery uses the platform's per-tenant mail configuration.
Failure semantics, per call site
A recipient that resolves to no address is a logged no-op - a record with nobody to mail must not stall a flow. A transitions[].notify is fail-soft: the status flip is the endpoint's contract and has already committed, so an SMTP problem is logged and the transition still returns success. A sending serviceTask, whose whole purpose is the message, fails the task instead, so the process engine's retry applies.
Links back to the application: {recordUrl}, {inboxUrl}, {appUrl}
"You have an approval waiting" is useless without the way back to the record, so subject: and body: accept three reserved link placeholders alongside the field ones:
| Placeholder | Resolves to |
|---|---|
{recordUrl} | the record the message is about, opened in the generated application |
{inboxUrl} | the recipient's process Inbox |
{appUrl} | the application's external base URL - the origin only |
notify:
to: Approver.email
subject: "Approval needed: invoice {number}"
body: "Open it here: {recordUrl}\nEverything waiting on you: {inboxUrl}"All three names are reserved at every notify call site, so an entity field of the same name never shadows them (and declaring one is a mistake worth avoiding). Never hand-type a route into a body: {recordUrl} and {inboxUrl} are resolved for you to the complete address - <base>/services/web/<project>/gen/<model>/index.html#/<Entity>/<id>/edit and .../index.html#/inbox respectively. {appUrl} yields the origin alone; reach for it only for an address the other two cannot express, such as a page of your own.
The origin comes from the DIRIGIBLE_APP_BASE_URL configuration, which is tenant-overridable - the same instance mails each tenant its own host - and is read per dispatch inside the sending tenant's configuration scope. Leave it unset and the links render relative to nothing; set it to the externally reachable origin of the instance (https://apps.example.com).
Why the intent never writes the path
The routes belong to the template that renders the application, not to the model. An intent that spelled one would encode a layout it does not own - correct only until that layout changes, and silently wrong afterwards. The intent layer contributes the entity and its key; the events template composes the address, exactly as it already does for the task form's __entityUrl.
Inside a forEach fan-out {recordUrl} links the row, like every other bare path in the block - the row is what that message is about, while {record.<field>} reads the anchor record.
One message per related row: forEach
Some sends are per-row rather than per-record - a payroll run mails every payslip to its own employee. forEach: names a related entity and the block sends ONE message per row of it; from then on every path (recipient, placeholders, attach: print) resolves against the ROW.
notify:
forEach: Payslip # rows whose to-one FK points at this record
to: Employee.email # the ROW's employee
subject: "Payslip {PayrollRun.month}" # one hop from the ROW
body: "Dear {Employee.name}, net pay {net}." # the ROW's own field
attach: print # the ROW's own documentThe row entity must have exactly one to-one relation back to the record - none means the rows are unrelated, several make the intended set ambiguous, and both are parse-time errors rather than a silently wrong list of recipients. The generated code loops the rows with the loop variable named entity, so the same pre-rendered expressions serve both shapes.
A fan-out is generated on a transitions[].notify and a serviceTask's args.notify. A schedules[].notify already runs once per matched row and a notifications[] entry is about the event record, so a forEach on either is a parse-time error rather than a declaration that is quietly ignored while a different message goes out.
One document, many recipients: attach: recordPrint
The mirror shape: the related rows are only the recipient list and the document belongs to the record they hang off - a request for quotation mailed to each invited supplier, an agenda mailed to each participant. attach: print cannot express it (it renders the ROW, which is nobody's document); attach: recordPrint renders the fan-out's anchor record - the record the block is about - ONCE, before the loop, and attaches the same PDF to every message.
notify:
forEach: InvitedSupplier # the rows: the recipient list
to: Supplier.email # the ROW's supplier - the rows ARE the recipients
subject: "RFQ {record.number}" # {record.<field>} = the ANCHOR RECORD's field
body: "Dear {Supplier.name}, please quote by {record.deadline}." # bare = the ROW
attach: recordPrint # the RECORD's document, rendered oncerecordPrint needs a forEach (without one, attach: print already renders that very record) and it is the anchor that must be a document - the row need not be. language: / languageFrom: then select the anchor's render language, read off the anchor: the generated delegate calls a renderDocument(source) before the loop - and not at all when the fan-out has no rows, so an empty recipient list costs no render and cannot fail a step for nobody.
Which record a path reads is authored, never inferred
Inside a fan-out a bare path - the recipient, {field}, {Relation.field} - resolves against the ROW, and the reserved prefix record. is the only way to address the anchor record: {record.<field>} names ONE field of it (a walk on from the record is rejected). The recipient may never be record-scoped: the rows are the recipients, so a record-scoped address would mail the same person once per row. record. outside a fan-out is rejected too - there the bare placeholder already IS the record's. All of it is checked at parse time, because nothing in a rendered message would show that the wrong record had been read.
A fan-out never fails its activity
Fail-soft per row at every call site, including a serviceTask (which otherwise fails): a row with no recipient is skipped, a delivery failure is logged, and the step completes with a per-row summary. Retrying would resend to every recipient already served - a partial fan-out cannot be made idempotent.
A sending serviceTask stands alone: notify cannot be combined with setField / setRelationField / call / delegate on the same step - give the send its own step and route to it with next.
processes:
- name: InvoiceIssue
trigger: { onCreate: Invoice }
steps:
- { name: issue, kind: userTask, args: { assignee: issuer, setRelationField: Status, value: 3, next: mailIt } }
- name: mailIt
kind: serviceTask
args:
notify: { to: Customer.email, subject: "Invoice {number}", body: "Attached.", attach: print }
next: end
- { name: end, kind: end }schedules
Cron reminders / cleanups - query an entity and act per matching row.
schedules:
- name: staleOrders
cron: "0 0 9 * * ?"
entity: Order
where:
- { field: orderDate, op: lt, value: CURRENT_DATE } # eq/ne/gt/ge/lt/le/like
notify:
to: ops@example.com
subject: "Stale order {id} for {customer.name}"
body: "This order is stale."Generates a gen/events/<Name>Job.java @Scheduled JobHandler that runs a typed Criteria query (where to typed conditions, CURRENT_DATE / CURRENT_TIMESTAMP to now) and performs, per matching row, exactly one of notify or generate (the notify form uses the same relation-load + interpolation as notifications).
A where value relative to now - the staleness sweep
The archetypal schedule is a staleness sweep: rows still provisioning after 30 minutes, quotations unanswered for a week, carts abandoned for an hour. Everything about such a sweep is a schedule already - except how old is too old, which needs a moment relative to now. Write the token with one signed ISO-8601 duration:
schedules:
- name: stuckProvisioning
cron: "0 */5 * * * ?"
entity: TenantApplication
where:
- { field: provisioningStatus, op: eq, value: Provisioning }
- { field: changedAt, op: lt, value: "CURRENT_TIMESTAMP-PT30M" } # stuck for 30 minutes
notify:
to: ops@example.com
subject: "Application {id} has been provisioning for over 30 minutes"
body: "It may need an operator."
- name: unansweredQuotations
cron: "0 0 8 * * ?"
entity: Quotation
where:
- { field: status, op: eq, value: Sent }
- { field: sentOn, op: lt, value: "CURRENT_DATE-P7D" } # no answer for a week
notify: { to: owner.email, subject: "Quotation {id} has had no answer for a week" }The offset resolves against the clock of the run that fires, not of the generation - the emitted query is Criteria.create().lt("ChangedAt", java.time.LocalDateTime.now().minus(java.time.Duration.parse("PT30M"))). The forward form (+) is admitted symmetrically, for "falls due within the next week".
The comparison happens in the queried field's own shape, so the token and the field have to agree:
| Field type | Token | Offset amounts |
|---|---|---|
date | CURRENT_DATE | date-only: P7D, P1W, P1M, P1Y |
timestamp | CURRENT_TIMESTAMP (or NOW) | any: PT30M, PT12H, P7D, P1M |
A moment vocabulary, not an expression language
Exactly one offset on one token. No arithmetic between fields, no nesting, no other operators. Each of the following is an authoring error at Generate rather than a query that quietly never matches: a token of the other shape than the field (CURRENT_TIMESTAMP against a date column), a time offset on a date field (CURRENT_DATE-PT30M), a second offset (CURRENT_TIMESTAMP-P7D-P1D), an offset that is not an ISO-8601 duration (-30M), and a moment compared with a non-temporal field.
Note the shape check applies to a bare token too, since "compared in the field's own shape" is the rule for the whole value form - so a model that compared a date column with CURRENT_TIMESTAMP (previously a silent never-match) now says so.
A field the source does not declare is left alone, which is what lets a sweep query an audit: true column (updatedAt) or a field of a cross-model source: there the token decides the shape, as it always did. And note what this construct replaces - a stored "deadline" column every writer has to keep up to date, i.e. modelling the clock into the data.
The generate variant creates a record through the target's repository (so numbering, status init and calculated fields fire); the target may be cross-model via a uses: alias, and it may fan out children (one child per matching entity, or per working day of the period):
schedules:
- name: monthlyTimesheets
cron: "0 0 1 1 * ?"
entity: Employee
where:
- { field: status, op: eq, value: ACTIVE }
generate:
to: EmployeeTimesheet # cross-model target via a uses: alias
map: { Employee: id }
defaults: { Period: now }
children:
- to: EmployeeDayAllocation
parent: EmployeeTimesheet
forEach: { days: workingDays } # one child per working day
dayField: dayCross-model source (model:)
The source entity is a local entity by default. Add model: <uses alias> to read the source from another (owner) model, so the schedule can live with the module that owns the CREATED rows instead of being forced into the source's module with a back-reference. The generated JobHandler imports the owner's gen.<owner>.data... classes and only READS them (a schedule never writes its source). A forEach collection may likewise be cross-model with its own model: alias. Both aliases must be declared under uses:.
uses:
- { model: projects }
schedules:
- name: monthlyProjectTimesheets
cron: "0 0 2 1 * ?"
entity: Project
model: projects # the source Project lives in the projects model
where:
- { field: Status, op: eq, value: 2 }
generate:
to: ProjectTimesheet # LOCAL - owned by this model, no uses: needed
map: { Project: id, Customer: Customer }
defaults: { Period: now }
children:
- to: EmployeeTimesheet
parent: ProjectTimesheet
forEach:
entity: EmployeeProjectAssignment
model: projects # the forEach collection is also cross-model
match: { Project: id }
map: { Employee: Employee }generateonly. A cross-model source with anotifyaction is rejected at parse - notify needs the source's relation metadata, which only a local entity carries. Keep such a schedule in the source's model, or dropmodel:.- Validation split (the same one relations use): that
model:names a declareduses:alias is checked at parse; the source entity's existence and thewhere/map/matchfield references are checked at generation against the owner's.model(generate the owner model first, or install/publish its prebuilt module). A missing owner or a mistyped field drops that schedule with a warning in the generate response - it never emits a job that cannot compile.
integrations
Tell another system on an event (outbound HTTP).
integrations:
- name: pushOrderToWarehouse
event: { onCreate: Order }
method: POST
url: "@config:WAREHOUSE_URL"Generates a gen/events/<module>/<Name>Integration.java self-describing MessageHandler that calls the URL via sdk.http.HttpClient. The @config:KEY sugar resolves to Configurations.get so endpoints and secrets stay out of the source. Custom headers are still a later increment.
payload - the declared envelope
Without a payload, the request body is the record as stored. That is only right when the receiver accepts the entity, and it has a cost even then: every column becomes a public contract, so adding a field silently changes what the outside world receives. A real integration contract is usually an envelope - a type, a version, an idempotency key, a timestamp, an identifier of the sender - which no arrangement of entity columns can produce.
payload declares that envelope, key by key:
integrations:
- name: requestUserAssignment
event: { onCreate: UserInvitation }
method: POST # a payload needs POST / PUT / PATCH
url: "@config:ASSIGNMENT_URL"
payload:
type: "user.assignment.requested" # literal
version: 1
messageId: "{uuid}" # minted per message
tenantId: "{tenant}" # execution context
appId: "@config:APP_ID" # configuration
email: email # a field of the record
role: role.name # one hop off a to-one relation
requestedAt: "{now}"The generated handler then reads the record, loads each referenced relation once (the same one-hop mechanism a notification uses), builds the map in the authored key order and posts Json.stringify(payload) - so what the model says is exactly what goes on the wire.
The value forms are the ones notify already resolves, deliberately borrowed rather than invented: a literal, a direct field, or a one-hop relation.field of a to-one relation. @config:KEY reads the configuration, as it does in url. The context tokens are a closed set of four:
| token | resolves to |
|---|---|
{uuid} | java.util.UUID.randomUUID() - the idempotency key a receiver deduplicates on |
{now} | java.time.Instant.now(), as an ISO-8601 string |
{tenant} | sdk.core.Tenant.getId() - the tenant the send runs for |
{user} | sdk.security.User.getName() - the user behind the change |
The cap is the point: three value forms and four tokens express a frozen contract without the block becoming a transformation language. What is refused, and refused at parse, not silently at run time:
- Interpolated text.
"Order {id} placed"is rejected - a payload value is one whole value, not a template. - A nested object or list. Same reason.
- A multi-hop path.
customer.country.nameis rejected, exactly as in a notify recipient. - An unknown context token.
{today}fails the parse rather than shipping an empty value. - A payload on a method with no body. GET and DELETE send nothing, so a payload there would be built and discarded.
One thing to know about literals: a bare word that names no field and no to-one relation of the record is a literal (that is how source: erp and type: "order.placed" work at all - YAML quoting does not survive the parse). When you mean a reference and want the parser to check it, brace it: email: "{email}".
inbound - arrivals from outside
Another system tells us - a JSON record shaped like the entity, ingested into it. What differs between the three forms is only where the record arrives; the action is the same create, through the same repository.
inbound:
# HTTP - an endpoint the other system posts to
- { name: ingestOrder, path: /ingest, create: Order }
# message - every record arriving on a queue (point-to-point) or a topic (broadcast)
- { name: ordersQueue, source: { queue: orders.inbound }, create: Order }
- { name: ordersFeed, source: { topic: crm.orders }, create: Order }
# file - every file dropped into a folder, polled on the cron
- { name: ordersDrop, source: { folder: /data/inbox/orders, cron: "0 */5 * * * ?" }, create: Order }An entry declares exactly one arrival: a path, or a source naming exactly one of queue / topic / folder - both, neither, or two channels fails at Generate. What each one generates under gen/events:
| Arrival | Generated class | Shape |
|---|---|---|
path | <Name>Webhook.java | a @Controller with a @Post("<path>"), served at /services/java/<project>/gen/events/<module>/<Name>Webhook<path> |
source: { queue | topic } | <Name>Consumer.java | a self-describing MessageHandler (destination() / kind()) on the platform broker |
source: { folder, cron } | <Name>FileImport.java | a self-describing JobHandler (cron()) polling the folder |
Whichever it is, the record is saved through the entity's generated repository, so validations, translations and the create event fire exactly as for any other write - the arrival is a transport, not a second data path.
A folder is polled, not watched
That is why a folder source requires its cron (and why a cron on the other sources is rejected). A file holds one record or an array of them; a file modified within the last few seconds is left for the next tick (it may still be being copied in); and every read file leaves the drop folder - into processed/ or, if it could not be ingested, failed/ - so nothing is ever ingested twice and a rejected file stays inspectable.
A queue or topic name is tenant-scoped unless you say otherwise
A destination is renamed per tenant on the broker (<tenantId>###<name>), which is what keeps one tenant's messages out of another's - and what makes a name another deployment publishes to unreachable, since it knows nothing of your tenants. When the queue or topic is a contract with a system outside this deployment, mark it global::
inbound:
- { name: ordersFromMarketplace, source: { queue: "global:codbex.orders" }, create: Order }global:codbex.orders resolves to the physical destination codbex.orders in every tenant, so the other side binds to the plain name it was given, and the arrival is subscribed once for the deployment rather than once per tenant. A name without the marker stays the application's own, which is the right default for anything published from within the same instance. See Message listeners - global destinations for what the marker costs: the destination no longer carries the tenant, so a business tenant that matters downstream has to travel in the payload.
Conversation-shaped transports - acknowledgements, retries with backoff, certificates, SFTP - stay outside the intent by design: use a Camel route in the same project, feeding the entity's ordinary write path.
When the payload is an envelope, not the record
Everything above assumes the arriving JSON already is the entity, field for field. A real arrival contract is not - it is an envelope, with a type, a version, some business keys and only then a few record fields:
{ "messageId": "9f9d1c9e-...", "type": "user.assignment.requested", "version": 1,
"tenantId": "acme", "email": "new.user@example.com", "role": "User" }Two optional keys read that envelope. Both work on any of the three arrivals - what the payload looks like has nothing to do with what it travelled on - and an entry that declares neither behaves exactly as described above:
inbound:
- name: userAssignments
source: { queue: "global:codbex.user-assignment-requests" }
accept: { type: user.assignment.requested, version: 1 }
create: TenantUserAssignment
map:
messageId: messageId
email: email
tenant: { lookup: Tenant, by: tenantId, from: tenantId }
role: { lookup: AssignmentRole, by: name, from: role }map: projects the envelope's keys onto the record's own - a key the map does not name is not the record's business. Each value is either an envelope key or a lookup.
lookup: is the one that matters most. The envelope says tenantId: "acme" and the record stores the Tenant foreign key; resolving one to the other is the single most common requirement of any arrival, and on its own the reason a fully modelled arrival still needed a hand-written consumer. from: is the envelope key, lookup: the entity to read, by: the field of it the business key matches.
by: must be unique, and a miss rejects the arrival
by: names a unique: true field of the looked-up entity (or its primary key). A lookup that could match several rows would silently pick one, which is worse than failing - so a non-unique by: fails at Generate rather than in production. And a lookup that matches nothing rejects the arrival, logging the value it could not resolve: a webhook answers 400, a queue message is logged and dropped, a drop file moves to failed/ whole so it can be re-dropped rather than half-ingested. What it never does is store the record with a null relation.
accept: gates on the envelope keys it names. A message that does not match is acknowledged and ignored with a warning - never failed. Failing it would only have it redelivered, and a sender rolling out version: 2 must not fill this receiver's error queue with messages it will keep sending. A webhook answers 202 Accepted (the sender did nothing wrong; this receiver simply does not handle it), and a record inside a drop file is skipped while the file still counts as processed.
Whichever keys are declared, the record still saves through the entity's generated repository, so validations, translations and the create event fire as for any other write. Two boundaries to know: do not map the primary key - it is generated on insert, so give the arrival's own identifier a field of its own with unique: true (which is also what makes a redelivery refuse itself) - and a lookup reads an entity declared in the same model; a cross-model lookup is not supported yet.
outbound - departures to another system
The mirror of inbound: the application raises a business event for something outside it, on a queue or a topic. Reach for integrations when you are calling someone's API and want their answer; reach for outbound when you are announcing that something happened and nobody answers. That difference in failure semantics - a failed call versus a missed announcement - is why these are two blocks rather than one with a transport switch.
outbound:
# the record's own JSON, on a queue - one consumer takes each message
- name: publishOrder
event: { onCreate: Order }
to: { queue: "codbex.orders" }
# a declared envelope, on a topic - every subscriber gets it
- name: announceActivation
event: { onStepCompleted: { process: OrderApproval, step: activate }, when: "channel != internal" }
to: { topic: "codbex.order-activations" }
payload:
type: "order.activated"
version: 1
messageId: "{uuid}"
tenantId: "{tenant}"
reference: number
customer: customer.nameAn entry declares exactly one channel: to: names a queue or a topic - both, neither, or two fails at Generate, which is the arrival rule above read backwards. It binds to the same event axis as everything else on this page, including its when: guard, and takes the same payload envelope as an integration. Without a payload the body is the record's own JSON - exactly what an integration forwards over HTTP today.
What it generates under gen/events:
| Departure | Generated class | Shape |
|---|---|---|
to: { queue | topic } | <Name>Publisher.java | a self-describing MessageHandler subscribed to the record's own event topic, re-publishing through sdk.messaging.Producer.sendToQueue / sendToTopic |
The publisher being a subscriber is the whole design. Every generated repository already publishes each write on an internal topic - that is what the rest of this page listens to - so a departure needs no new mechanism and no touch to the write path: it subscribes where a notification would, and sends instead of mailing.
Delivery semantics - stated, not implied
The message is published after the write that raised the event is persisted, and is not transactional with it. A failed publish is logged and the write stands - the same rule the notify block sets. There is no outbox, no exactly-once delivery and no ordering guarantee. If a contract needs any of those, it needs a real integration platform, not this block.
A departure destination follows the same naming rule as an arrival: without a marker it is the application's own and is renamed per tenant on the broker, which is right for a channel this deployment both publishes and consumes. When the queue or topic is a contract with a system outside the deployment, mark it global: so the other side binds to the plain name it was given:
outbound:
- { name: publishOrder, event: { onCreate: Order }, to: { topic: "global:codbex.orders" } }See the global: note under inbound above, and Message listeners - global destinations for what the marker costs - the destination no longer carries the tenant, so a business tenant that matters downstream has to travel in the payload (a tenantId: "{tenant}" key in the declared envelope is exactly that).
rollups
Maintain a denormalized counter on a parent.
rollups:
- name: customerOrderCount
entity: Order # the child being counted
via: customer # the to-one relation up to the parent
field: orderCount # the parent field to writeGenerates four gen/events/<Name>RollupOn{Create,Update,Delete,Rekey}.java @Listeners on the child's create / update / delete / rekey topics that recompute the affected parent's count via a typed Criteria and write it back. Recompute-on-event (self-healing), so it is eventually consistent, not transactionally exact under heavy concurrency. It counts all children (no where filter yet).
Moving a child to another parent is an ordinary edit of its parent relation, and both parents end up right. The update listener recomputes the parent it moved to; the rekey listener recomputes the one it moved away from, fed the child's PREVIOUS row on the dedicated -rekeyed topic that the DAO publishes whenever a grouping column actually moves. Every handler recomputes the parent the payload names, so the same class repairs either side.
The -rekeyed publish is not tied to the form submit. A targeted write - a process step's setRelationField, a resolves: lookup, a task form persisting the fields it edited - moves that column too and deliberately raises no -updated event, so it publishes both the previous and the written row on -rekeyed: the only topic the roll-up and aggregate handlers subscribe to, which is how both sides are repaired without re-firing every reaction bound to the record.
With op: sum the roll-up instead keeps field equal to the sum of the children's of field, and can maintain a balance (= capacity - sum) and flip a status relation to statusWhenFull / statusWhenPartial - the invoice paid / balance / PAID-PARTIAL pattern. Sum roll-ups also compose transitively across a multi-level composition (leaf edit to mid total to top total). See rollups in the DSL reference.
rollups:
- { name: invoicePaid, entity: Allocation, via: SalesInvoice, field: paid,
op: sum, of: amount, capacity: total, balance: balance,
status: Status, statusWhenFull: 7, statusWhenPartial: 6 }A status the roll-up sets, it also lets go of. The first move into statusWhenFull / statusWhenPartial remembers the status it displaces in a hidden Displaced<Status> column the generator adds to the parent, and a sum that returns to zero - the only allocation deleted, amended to 0 or re-parented away - restores it. A paid invoice whose allocation is removed is CONFIRMED again, or ISSUED if it was paid straight from there - not PAID with nothing paid, invisible to the settlement. Only a status the roll-up itself set is relinquished: a document voided or cancelled by hand while partially paid stays that way when its allocation goes. There is no statusWhenEmpty - a declared return status is wrong for every document that entered the paid region from a different status than the declared one, and the remembered status never is. With a lifecycle: on the parent, the moves back must be declared edges just like the moves in.
The parent may be owned by another model: with a cross-model via the handler resolves the parent's package and perspective from the owner's .model and writes through the owner's repository. The model must be declared in uses:, and an unresolvable roll-up is surfaced in the generate response's issues rather than dropped.
The child may be the foreign one instead - the direction an n:m pairing forces, because a link entity lives with the document that owns one side of it while the other side's total belongs to the module that owns that side. Name the owner with model: and the local entity the total lands on with parent::
uses:
- { model: sales-invoices }
rollups:
# CustomerPayment.allocated = the sum of the payment's allocation rows, owned by sales-invoices;
# unapplied = amount - allocated, the figure "is this payment fully applied?" actually asks for.
- { name: paymentAllocated, entity: SalesInvoiceCustomerPayment, model: sales-invoices,
parent: CustomerPayment, via: CustomerPayment, field: allocated,
op: sum, of: amount, capacity: amount, balance: unapplied }The handler subscribes to the OWNER project's topic and reads the rows back through the owner's repository; the owner model is unchanged and unaware, and the dependency edge stays one-way. via names the foreign child's own to-one relation to the parent, and via / of / by are checked against the owner's generated model at Generate time - a property it does not declare, or a via that references some other entity, drops the roll-up with an issue rather than silently summing the wrong rows. Two limits worth knowing: capacity / balance / status are maintained (they are writes on the local parent) but the overdraw guard is not installed, because that check belongs to the child's own write path in the owner module - Generate says so; and moving a foreign row between parents repairs the parent it moved TO immediately, while the one it left is repaired only if the owner model publishes a re-key notice for that relation (deleting and re-creating the row is exact either way).
aggregates
A total over one entity's rows grouped by SEVERAL to-one relations, materialised into its own entity keyed by the same relations (on-hand per product and store, exposure per customer):
aggregates:
- { name: onHand, of: StockMovement, op: sum, sum: quantity,
by: [Product, Store], into: ProductAvailability, field: onHand }Four handlers per aggregate (source create / update / delete, plus rekey) upsert the target row for the incoming row's key-tuple and recompute from every source row sharing it. The rekey handler is fed a row whose grouping moved, on a dedicated -rekeyed topic the DAO publishes only when a grouping key actually changed: the PREVIOUS row, which repairs the tuple the row left behind, and - on the targeted write path, which raises no -updated at all - the written row too, which repairs the tuple it moved into. The write is targeted (updateDerived), so only the aggregate column is persisted. Unlike rollups, the total lives in a referenceable entity rather than on a composition parent. See the DSL reference.
posts
Derived rows into a ledger on a document status event, mapped from the document and its items, idempotent by a declared back-reference:
posts:
- { name: goodsReceiptLedger, event: POSTED, forEach: items, into: StockMovement,
idempotentBy: GoodsReceipt, set: { Product: item.Product, Quantity: item.Quantity } }The generated handler listens on the source's -transitioned topic, skips when rows already back-reference the source, and writes through the target repository so its numbering and checks still fire.
resolves
A to-one filled from the row of a register whose validity period covers a date the record carries - the driver from a vehicle-assignment register on the violation date, the price from the list in force on the order date, the approver from the org assignment on the request date:
resolves:
- { name: identifyDriver, event: { onCreate: Fine }, set: driver, from: VehicleAssignment,
match: { vehicle: vehicle },
between: { start: validFrom, end: validTo, value: violationAt },
outcome: resolution, found: { setStatus: IDENTIFIED }, notFound: { setStatus: UNRESOLVED },
ambiguous: { setStatus: UNRESOLVED } }The generated handler listens on the record's event topic, queries the register by the match keys with a typed Criteria, and keeps the rows whose period covers the date. All three outcomes are first-class: exactly one covering row fills the relation, while none and more than one both leave it unset - it never picks one of two candidates. Each outcome may flip the record's status, and outcome: stamps found / notFound / ambiguous into a string field, which is what makes the unresolved ones a worklist rather than a silent gap. The relation, the outcome and the status go out in one targeted updateProperties. See the DSL reference.
phases - the enrichment axis
Some of what a record needs is not known when the row is inserted. A stock movement's cost comes out of a moving-average pool; a snapshot column is copied from a register; an identifier comes back from an external system. Those values are computed by a listener on the record's own create event and written back afterwards - and that write-back must not publish, or it would re-fire every onUpdate consumer for a change the user never made.
So the enrichment is silent, and that is where it used to go wrong. A posting bound to onCreate runs as a sibling of the enrichment listener, and two listeners on one topic have no defined order between them: each is its own durable subscriber. The posting could therefore read the row before the cost was written, and post a perfectly balanced journal entry for a null amount - with the parse, the generation, the compile and the publish all green. Nothing anywhere said the value was not ready yet.
A phase gives that write its own channel. The entity declares the moments it announces:
entities:
- name: StockMovement
phases: [costed]
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: costValue, type: decimal, precision: 18, scale: 2 }The generated StockMovementRepository then carries one announce<Phase> method per declared phase, and the enriching listener writes through it:
new StockMovementRepository().announceCosted(movement.Id, java.util.Map.of("CostValue", cost));That is a single targeted write which also publishes the phase's topic, recorded in the tenant's event outbox inside the write's own transaction - so the value and the notice commit together and no consumer can ever observe one without the other. Any glue consumer binds the phase instead of the insert:
postings:
- name: cogsPosting
event: { onPhase: StockMovement, phase: costed }
creates: JournalEntry
backReference: StockMovement
rule: { entity: PostingRule, match: { documentType: "Goods Issue" } }
items:
- { Account: rule(costOfSalesAccount), debit: "CostValue" }
- { Account: rule(inventoryAccount), credit: "CostValue" }onPhase is accepted by postings, notifications, integrations, outbound departures and an event-driven generates. Its when: guard is optional - the phase already is one moment, where a transition is any status write.
Why a method and not a topic string
The announce<Phase> method exists so the channel is never hand-typed. A mistyped topic string binds to something nothing publishes to, and the consumer simply never fires - the very silence the phase axis removes. A mistyped announceCosted is a compile error the Problems view shows.
What is rejected at Generate
A phase name that is not a lower-camel identifier (it becomes both a method name and a topic); a phase named after one of the platform's own channels (updated, deleted, transitioned, rekeyed - announcing it would re-fire that channel's consumers); a duplicate; a phase: key on any other axis; and a consumer binding a phase the entity does not declare. A cross-model source declares its phases in its own model, so the name cannot be checked from the consumer's side there.
Declare a phase only for what a listener adds after the insert. A calculatedOnCreate expression, a calculatedActionOnCreate action, a number: stamp and a document's own totals are all in the row the create event already carries.
Lifecycle triggers
A process trigger (start a process on an entity event) is the original glue and is documented with processes, including the configurable businessKey and businessKeyStrategy: timestamp. Decision resolvers (load a related entity's field at a gateway) are also glue, emitted into <intent>.glue.
Waits and boundary timers
The process-side "observe the outside world" primitives (wait, timeout:, expire:) each generate their own glue class under gen/events:
- a wait listener (
<Process><Step>Wait.java, thewaitscollection) - aMessageHandleron the event entity's topic that applies thewhen:guard, resolves the record carrying the parked instance'sProcessId(through thevia:back-reference, or the event record itself), and correlates the catch event's message fail-soft; - an expire date loader (
Load<Process><Task>Expire.java, thetimerLoaderscollection) - aJavaDelegateinserted before the user task that re-reads the trigger entity's date field at task entry and publishes thejava.util.Dateprocess variable the cancelling boundary timer arms from.
What one intent can declare today
The event-then-action glue above, plus the data-flow glue documented in the DSL reference - settlements, expansions, generates and postings - are all generated as the same annotated client-Java under gen/events.
| Glue | Status |
|---|---|
Lifecycle triggers (process start, when guard, business key + timestamp strategy) | implemented |
Decision / form resolvers (relation.field at a gateway or on a task form, local or cross-model) | implemented |
Notifications (email; literal / field / one-hop relation; when) | implemented |
Send a document by e-mail (a notify block with attach: print, on a process step / transition / schedule) | implemented |
Schedules (cron to typed-Criteria query; per-row notify or generate) | implemented |
Integrations (event to HttpClient) | implemented |
Process-step events (onStepReached / onStepCompleted on a notification, an integration, a departure or a create-from) | implemented |
Inbound arrivals (webhook @Controller, queue/topic MessageHandler, polled-folder JobHandler) | implemented |
Outbound departures (event to a queue / topic via Producer, with the declared envelope) | implemented |
Event-driven create-from (generates with event:, on either axis, mode: once or append) | implemented |
| Rollups (count, and sum + balance + status) | implemented |
| Settlements (auto-allocate payments across open invoices) | implemented |
| Expansions (generate child rows from a date span) | implemented |
| Generates (one-click document-from-document create) | implemented |
| Postings (source document to balanced local document) | implemented |
Effective-dated register lookup (resolves - fill a to-one from the row valid on a date) | implemented |
Owner-based user-task assignment (assignee: personal) | implemented |
Resolver-path task assignment (assignee: { path, fallback } - a to-one walk off the trigger record) | implemented (see DSL reference) |
Waits (wait step - park on an entity event, correlate by ProcessId) | implemented |
Boundary timers (userTask timeout: reminder / expire: date-driven withdrawal) | implemented |
| Standard per-document PDF print templates | implemented (see Printing) |
| Event-driven document generation (produce a PDF on an event) | implemented - mailed by attach: print, stored by function: Snapshot |
Status lifecycle / declarative state machine (lifecycle: - the whole legal status graph, enforced on every status write) | implemented (see DSL reference) |
Audit history (shadow <Entity>History entity; audit columns via audit: true ship today) | planned |
Guardrails
- Curated vocabulary, not a DSL. Real logic is a
scriptstep or acustom/hook - the escape hatch is non-negotiable. - Every generated glue artefact has an override switch via
.settings(overrides.{...}.generate = false), so a hand-writtencustom/class can replace any single generated one. - Secrets and endpoints via
@config:/Configurations, never inline. - Bindings validated at parse - a dangling
customer.namezfails fast, not at runtime. - Determinism, diff stability and comment preservation, as for every generator; each generated class carries a "generated from intent - do not edit" header.
See also
- The
.intentfile - Generators and generation
- Message listeners and scheduled jobs - the SDK surfaces the glue generates against
- Java SDK