The problem
The format can describe a record arriving on a queue or topic and cannot describe one leaving.
inbound: gained arrival sources; integrations: — the only "tell another system" construct — is
HTTP by construction: a method and a URL. There is no transport axis, so an application that raises a
business event for another system falls out of the format entirely.
Two things make this asymmetry hard to defend:
- The mirror argument that admitted arrivals applies unchanged. An arrival was accepted as "a
transport, not a second data path". A departure is the same one-line call-out shape as the HTTP
POST already specified, aimed at a different address.
- A conforming application is already an event publisher. In the reference implementation every
generated write publishes the record on an internal topic, and the whole glue layer is subscriptions
to those topics. The behaviour exists; only the vocabulary to point one outward is missing.
The second half of the problem is the payload. Everything the format sends outward today is "the whole
record, as stored". Real contracts are envelopes:
{ "messageId": "9f9d1c9e-...", "type": "user.assignment.requested", "version": 1,
"tenantId": "acme", "appId": "library", "email": "new.user@example.com", "role": "User" }
Two literals, a minted idempotency key, a value from the execution context, one from configuration, and
only then some record fields. No construct produces that, so the most common integration shape in
practice is inexpressible even once a transport exists. Publishing the raw record also makes every
field a public contract, so adding a column silently changes what the outside world receives.
The proposed shape
A departure block mirroring inbound:, and a declared payload usable by both it and integrations:.
outbound:
- name: publishOrder
event: { onCreate: Order }
to: { queue: "orders.outbound" } # the record's JSON, as integrations: sends it today
- name: requestUserAssignment
event: { onCreate: UserInvitation }
to: { topic: "codbex.user-assignment-requests" }
when: "channel != internal"
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 same vocabulary answers the mirror gap on arrival — mapping a payload onto an entity, and
resolving a business key to a relation:
inbound:
- name: userAssignments
source: { queue: "codbex.user-assignment-requests" }
accept: { type: user.assignment.requested, version: 1 }
create: TenantUserAssignment
map:
email: email
tenant: { lookup: Tenant, by: tenantId, from: tenantId } # a name arrives; a relation is stored
Expected behaviour
Departure. to: names exactly one of queue / topic; both, neither, or two channels is an
error, mirroring the arrival rule. The entry binds to the same event axis every reacting block uses —
entity lifecycle events and process-step events — and takes the same when: guard, so no action needs
to know which axis fired it. With no payload: the body is the record's own representation, exactly
what an HTTP integration forwards today.
Payload values are a closed vocabulary, not an expression language. A value is a literal, a direct
field, or a one-hop relation.field of a to-one relation — the resolution rule the notify block
already defines, including its rejection of multi-hop paths. Configuration is referenced as elsewhere.
The context tokens are a closed set — {uuid}, {now}, {tenant}, {user} — and an unrecognised
token MUST be rejected at generation rather than resolved to nothing, because an empty value in a
shipped message is a contract breach discovered by the receiver.
Arrival mapping. map: omitted keeps the current behaviour, so existing files are unchanged. A
lookup: resolves a to-one relation by a unique field of the target; a generator MUST reject a
non-unique by:, since picking one of several matches silently is worse than failing. A lookup that
matches nothing MUST reject the arrival with a clear diagnostic rather than store a null relation.
accept: gates on declared keys; a message that does not match is acknowledged and ignored with a
warning, never failed into redelivery — a sender rolling out a new version must not fill the
receiver's error queue. Everything still saves through the entity's ordinary write path.
Delivery semantics, stated rather than implied. The departure is published after the write it
reacts to is persisted, and is not transactional with it: a failure is recorded and does not fail
the write, matching the rule the notify block already sets. Ordering, exactly-once delivery and an
outbox are explicitly not promised — a conforming implementation says so, because those are the
guarantees an author will otherwise assume. Conversation-shaped transports — acknowledgement
protocols, request-reply correlation, backoff policy — stay beyond the scope boundary, as they are
today.
An alternative shape considered. The transport could instead be a new key on integrations:
(to: replacing url:), avoiding a second construct that means "tell someone". That reads worse in
one specific way: integrations: is call another system's API and it answers, while this is emit an
event and forget, and their failure semantics differ accordingly. Naming the departure outbound:
also pairs it with inbound:, which is how authors will look for it. Both shapes are otherwise
identical, and the payload half is unaffected by the choice.
Prior art / workarounds
Hand-written publisher code alongside the generated application: a listener on the entity's own event
topic that assembles the envelope and calls the platform's producer. Mechanical, repeated in every
application that participates in the same integration, and — because the contract lives in that code —
invisible to anyone reading the model. The arrival half is worse: an application can declare its
entity, its deduplication, its process trigger and its arrival, and still need a hand-written consumer
purely because a relation arrives as a name instead of a number.
The problem
The format can describe a record arriving on a queue or topic and cannot describe one leaving.
inbound:gained arrival sources;integrations:— the only "tell another system" construct — isHTTP by construction: a method and a URL. There is no transport axis, so an application that raises a
business event for another system falls out of the format entirely.
Two things make this asymmetry hard to defend:
transport, not a second data path". A departure is the same one-line call-out shape as the HTTP
POST already specified, aimed at a different address.
generated write publishes the record on an internal topic, and the whole glue layer is subscriptions
to those topics. The behaviour exists; only the vocabulary to point one outward is missing.
The second half of the problem is the payload. Everything the format sends outward today is "the whole
record, as stored". Real contracts are envelopes:
{ "messageId": "9f9d1c9e-...", "type": "user.assignment.requested", "version": 1, "tenantId": "acme", "appId": "library", "email": "new.user@example.com", "role": "User" }Two literals, a minted idempotency key, a value from the execution context, one from configuration, and
only then some record fields. No construct produces that, so the most common integration shape in
practice is inexpressible even once a transport exists. Publishing the raw record also makes every
field a public contract, so adding a column silently changes what the outside world receives.
The proposed shape
A departure block mirroring
inbound:, and a declared payload usable by both it andintegrations:.The same vocabulary answers the mirror gap on arrival — mapping a payload onto an entity, and
resolving a business key to a relation:
Expected behaviour
Departure.
to:names exactly one ofqueue/topic; both, neither, or two channels is anerror, mirroring the arrival rule. The entry binds to the same event axis every reacting block uses —
entity lifecycle events and process-step events — and takes the same
when:guard, so no action needsto know which axis fired it. With no
payload:the body is the record's own representation, exactlywhat an HTTP integration forwards today.
Payload values are a closed vocabulary, not an expression language. A value is a literal, a direct
field, or a one-hop
relation.fieldof a to-one relation — the resolution rule the notify blockalready defines, including its rejection of multi-hop paths. Configuration is referenced as elsewhere.
The context tokens are a closed set —
{uuid},{now},{tenant},{user}— and an unrecognisedtoken MUST be rejected at generation rather than resolved to nothing, because an empty value in a
shipped message is a contract breach discovered by the receiver.
Arrival mapping.
map:omitted keeps the current behaviour, so existing files are unchanged. Alookup:resolves a to-one relation by a unique field of the target; a generator MUST reject anon-unique
by:, since picking one of several matches silently is worse than failing. A lookup thatmatches nothing MUST reject the arrival with a clear diagnostic rather than store a null relation.
accept:gates on declared keys; a message that does not match is acknowledged and ignored with awarning, never failed into redelivery — a sender rolling out a new version must not fill the
receiver's error queue. Everything still saves through the entity's ordinary write path.
Delivery semantics, stated rather than implied. The departure is published after the write it
reacts to is persisted, and is not transactional with it: a failure is recorded and does not fail
the write, matching the rule the notify block already sets. Ordering, exactly-once delivery and an
outbox are explicitly not promised — a conforming implementation says so, because those are the
guarantees an author will otherwise assume. Conversation-shaped transports — acknowledgement
protocols, request-reply correlation, backoff policy — stay beyond the scope boundary, as they are
today.
An alternative shape considered. The transport could instead be a new key on
integrations:(
to:replacingurl:), avoiding a second construct that means "tell someone". That reads worse inone specific way:
integrations:is call another system's API and it answers, while this is emit anevent and forget, and their failure semantics differ accordingly. Naming the departure
outbound:also pairs it with
inbound:, which is how authors will look for it. Both shapes are otherwiseidentical, and the payload half is unaffected by the choice.
Prior art / workarounds
Hand-written publisher code alongside the generated application: a listener on the entity's own event
topic that assembles the envelope and calls the platform's producer. Mechanical, repeated in every
application that participates in the same integration, and — because the contract lives in that code —
invisible to anyone reading the model. The arrival half is worse: an application can declare its
entity, its deduplication, its process trigger and its arrival, and still need a hand-written consumer
purely because a relation arrives as a name instead of a number.