Skip to content

[proposal] outbound - a record may leave on a queue or topic, with a declared payload #30

Description

@delchev

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions