Primer
A workflow in Yukta is a graph of steps. Something has to flow from one step to the next, and that thing must be safe to hand to several steps at once. If steps could change a shared object in place, a step on one branch could silently break a step on another. Teams also need to know where a piece of data came from, which request it belongs to and which steps it passed through. Messaging defines that unit of data (the message) and a hook (the channel) that core wraps around every step. It is the lowest module: core, plugin-api and every plugin depend on it, and it depends on nobody in Yukta.Where messaging sits
The vocabulary in eight cards
One message, through one node
A weather lookup. An API trigger creates a message, a node called ‘weather’ (an HTTP_REQUEST processor) enriches it, and two children receive the result. Click a step to see what happens, why, and where it can go wrong.Create
create(traceId, payload). The message gets a random UUID as its id, the current time, a trace id, empty metadata and empty history. Then it sets source port ‘default’.Why it works this way. A factory with safe defaults means every message starts with the same shape: no null maps, no null history, a timestamp always present.Can go wrong. Passing a null trace id (the control bus does) means the message has no trace. Nothing checks that a trace exists.Route in
error port.Why it works this way. Routing by port lets one node feed different children by outcome. Excluding the error port from unnamed edges means dead letters never leak into the happy path.Can go wrong. A plugin that forgets to set a port still works on unnamed edges but is silently dropped by an edge that names a port.Inbound hook
inbound. The default channel returns the stream unchanged.Why it works this way. This is the one seam where a deployment could inspect, delay, persist or forward every message entering a node without touching any plugin.Can go wrong. An error thrown here is not inside the node’s retry policy, so it is not retried or routed to the error port. A node that is skipped still goes through the channel, so an audit channel sees it.Plugin derives
DefaultMessage.from(original, body) and then withMetadata(...). The result keeps the id, trace id, timestamp and history, but has the new body and extra headers such as the HTTP status.Why it works this way. Deriving from the input, instead of creating from scratch, is how trace, correlation and sequence survive the node. The id also stays the same, so retries of the same message are recognisable.Can go wrong. Using create instead of from starts a new identity and an empty history: lineage is lost. Splitter and Aggregate do this on purpose.Outbound hook
outbound. Again the default returns it unchanged.Why it works this way. Symmetric to inbound: a custom channel could add a signature, size check or audit entry to every output.Can go wrong. Terminals have no output, so for them only the inbound side is ever called; triggers have no input, so only outbound.History stamp
Fan-out
A message in plain words
Born
Delivered
Wrapped
Changed
Wrapped again
Stamped
Shared
Components
Envelope Contract
the Message interface Defines what a message is: 22 read accessors and 18 copy-with-change methods, with no storage or transport decisions.-
Reading uses record-style accessors with no ‘get’ prefix:
msg.payload(),msg.traceId(). Writing uses ‘wither’ methods that always return a new message and never touch the original. - The accessors are grouped by purpose: identity (id, trace, correlation, reply-to), timestamp and expiry, routing (source port, source node, workflow id), sequence, priority and control flag, dead-letter fields, history, metadata and payload.
- The interface has about 40 methods but one implementation. The reason it is an interface at all is that core and plugins should compile against a contract, not against a record that may grow fields.
-
The id is documented as a logical id: it is kept across
withPayloadand every other copy. So it is not unique per node output. Any store must key entries by (execution id, node id, message id), or two different outputs would overwrite each other. -
The documentation of
withAddedHistorystates the rule that core stamps every node itself and plugins must not call it. - Guarantees. Every wither returns a new instance; reading a message never changes it.
- State. None. It is an interface.
- Ceiling. Each new field needs changes in the interface, the record, the two copy paths and the tests. A wide interface is costly to extend (see the risk on its width).
- On failure. Nothing runs here, so nothing fails. The contract can be broken only by an implementation that mutates, and there is only one implementation.
Envelope Record
DefaultMessage, 22 components The only implementation of the envelope: an immutable record that tidies its own maps and history when it is built.-
It is a Java record with 22 components, so equality, accessors and copying come for free. Lombok generates most withers and a builder with
toBuilder, so several fields can be changed in one copy instead of a chain of copies. - Metadata is cleaned in the constructor. A null map becomes empty. Entries whose value is null are dropped quietly, because an immutable map cannot hold nulls. A null key is rejected with a NullPointerException, because that would be a programming error and not ‘no value’.
-
History is cleaned in the constructor too: null becomes an empty list, and only the last 64 entries are kept (the constant is
MAX_HISTORY). So even a message built by hand or read back from a checkpoint file cannot carry more than 64. - The timestamp defaults to ‘now’ if not given. Both maps and lists are copied into unmodifiable versions, so a caller who keeps a reference to the list it passed in cannot change the message later.
- This immutability is shallow. The list and map are fixed, but the objects inside metadata and the payload itself are shared by reference. Nothing in the module copies or freezes them.
- The checkpoint store in core writes a message as JSON and reads it back as this record. The payload then comes back as plain JSON values (maps, lists, strings), not as its original Java type.
- Guarantees. A message always has non-null metadata and history, history is at most 64 long, metadata has no null values or keys, and a timestamp is present.
- State. None. Every instance is a value. Nothing survives a restart unless core checkpoints it.
- Ceiling. Each wither copies the whole record (22 fields). Metadata changes copy the entire map. That is fine for small headers and wasteful if many large headers are rewritten at every node.
- On failure. A null metadata key throws NullPointerException at construction. Any other bad input (a negative priority, a huge map) is accepted.
MAX_HISTORY = 64 (a constant, not configurable)
Factories
create and from The two named ways to get a message: a new logical message (create) or a new payload on an old message (from).
-
create(traceId, payload)makes a brand-new message: a random UUID id, the trace id as text (or null if none is given), the current time, empty metadata and empty history. Everything else is zero or null. -
from(original, newPayload)copies every one of the 22 fields of the original, including id, trace, timestamp, history and metadata, and swaps only the payload. The instance methodwithPayloaddoes the same. -
Triggers use
createbecause a trigger starts a new logical message (the API trigger, the schedule trigger and the constant source all do). Processors such as HTTP_REQUEST, LLM_CALL, MAPPER and PROCESS_EXECUTOR usefrom, which is why trace and id survive them. -
Splitter and Aggregate deliberately use
create: a split child is a new logical message with a new id (its correlation id points at the parent), and an aggregate is one new message built from many. Their history starts empty again. Splitter copies the parent’s trace id; Aggregate takes the trace id of the first message in the batch. -
Guarantees.
fromnever loses a field.createnever produces null maps or null history. - State. None.
- Ceiling. No uniqueness or validation: the factory trusts the caller (for example, two messages could be created with the same trace id by design).
-
On failure. If a plugin builds a message with
createwhen it should have usedfrom, trace, history and identity are silently lost. Nothing warns about it.
Header & History Ops
withers for plugin authors The small copy-with-one-change operations that plugins and core use to adjust headers, record failures and mark sequence positions.-
withHeader(key, value)adds or replaces one entry in the metadata map. A null value removes the key; a null key throws. So a plugin can ‘unset’ a header without a separate remove method. -
withMetadata(map)replaces the whole map (the record cleans it as always).withSourcePort(port)names the output the message leaves from; core filters edges by this value. -
withFailure(origDest, reason, detail)fills three dead-letter fields in one copy. Core’s retry policy and several router plugins use it, then set the source port toerrorso only error edges receive the message. It does not touch theretryCountfield; the retry count in dead letters lives in a header calledfailure.attempts. -
withSequence(id, position, total)marks a message as item N of M in a group.withAddedHistory(nodeId)appends one id and drops the oldest if there are already 64. Plugins must not call it: core does it for every node, and a second stamp would put a node in the list twice. - Convenience setters exist for trace id, correlation id, reply-to, source node, workflow id, expiration, format indicator, timestamp, priority, control flag and retry count.
- Guarantees. Each call returns a new message and leaves the original unchanged. Trace and correlation ids survive any chain of withers on a derived message.
- State. None.
-
Ceiling. A chain of three withers makes three copies of a 22-field record. Use
toBuilder()to change several fields in one copy. - On failure. Wrong use, not runtime failure: stamping history in a plugin double-counts nodes; setting a port name no edge listens for makes the message vanish.
Port Routing (core)
sets source node, filters Core’s step on every edge: put the parent’s node id on the message and decide whether this edge may receive it, based on the source port.-
For each parent edge, core sets
sourceNodeIdto the parent’s id by copying the message. This overwrites whatever was there, so a plugin cannot fake where a message came from. -
If the edge names a source port, only messages whose
sourcePortequals that name pass. If the edge names no port, every message passes except those on the port namederror. -
Why exclude the error port: with
onError: ROUTE, a message that failed all its retries is tagged with failure details and theerrorport. A normal edge must not receive it as if it had succeeded; only an edge that asks for the error port gets it. -
A message with no source port reaches unnamed edges but never an edge that names a port. The API trigger and several processors set the port
defaultexplicitly. -
This code lives in core (
StreamTopologyDecorator), not in messaging. It is shown here because it defines how two fields of the envelope are used. - Guarantees. A message never reaches an error-port edge unless it carries the error port, and never reaches an unnamed edge if it does.
- State. None. The decision is made per message from its fields.
- Ceiling. Filtering is a string comparison per message per edge. Not a bottleneck.
- On failure. An edge that names a port nothing emits just stays silent. The validator rejects an edge naming a port the plugin does not declare, so this mostly shows up with plugins that emit undeclared ports.
edge.sourcePort in the workflow JSON
Channel SPI
inbound / outbound hook A two-method hook core calls around every node, plus the provider that hands core the right hook for each node.-
The hook is
NodeMessageChannelwithinbound(nodeId, executionId, upstream)andoutbound(nodeId, executionId, pluginOutput). Each takes a Reactor stream of messages and returns a stream. A channel may transform, delay, filter, log or forward the messages. -
The provider is
NodeMessageChannelProvider.channelFor(nodeId, config). It receives the node’s id and config, so one provider can choose a different channel per node. Core calls it once while it builds the node, not once per message. -
Processor nodes use both sides. Trigger nodes have no input, so only
outboundis used. Terminal nodes have no output, so onlyinboundis used. - The channel sits outside the engine’s retry and error policy. For processors, the per-message retry wraps the plugin call, while the channel wraps the whole stream around it. A failure inside the channel therefore fails the node’s stream; it is not retried per message.
- A skipped processor still has its channel: core always builds the pipeline and reads the skip flag per message, so the channel sees the message and the plugin is bypassed.
- Guarantees. Core asks the provider for a channel for every trigger, processor and terminal it builds, and wraps the plugin stream with it.
- State. None in the interface. A custom channel can hold what it likes.
-
Ceiling. The streams are Reactor
Fluxvalues of in-process objects. There is no acknowledgement, no serialisation contract and no flow-control signal beyond Reactor back-pressure, so the hook is not yet proven for a remote or durable transport. -
On failure. An exception in
inboundoroutboundpropagates as the node’s stream error. The retry policy does not wrap it.
a NodeMessageChannelProvider bean (to replace the default)
History Stamp (core)
once per node, in core Core appends each node’s id to every message leaving that node, then checkpoints and fans it out.-
After the node’s output (and after the outbound channel), core maps every message through
withAddedHistory(nodeId). Only after that does it save the checkpoint, emit a log line and broadcast to children. - Because it is the single stamp point, a message that passed through three nodes has three entries, in order. A plugin that also stamped would add a duplicate.
- A skipped processor is still stamped, because the stamp is part of the node’s output decoration, not of the plugin call.
- Fan-out sends the same stamped message object to every child. Immutability makes that safe for the envelope itself; the shared payload is the only thing that could be changed by mistake.
-
No plugin in the repository calls
withAddedHistorytoday; only core does. - Guarantees. Exactly one history entry per node per message, added by core. The checkpoint copy already includes it.
- State. History travels inside the message. It is checkpointed with it (files or memory, per core’s setting).
- Ceiling. The list is capped at 64 entries, oldest dropped first. Messages that move through more than 64 nodes (for example in a retry loop) lose the start of their journey.
- On failure. Nothing here fails. The loss is silent: the oldest entries are discarded without a marker.
Direct Channel
no-op default The default channel: both methods return the stream unchanged, and one shared instance serves every node.-
inboundandoutboundeach return their argument. There is no copy, no buffer and no operator added to the Reactor chain. - The provider returns the same singleton for every node and ignores the node id and config.
- This is why the hook costs nothing today: the node’s stream has the same shape with or without the channel.
- Tests check that the stream object returned is the very same one that went in, and that the provider hands back the same instance for different nodes.
- Guarantees. A message is delivered to the plugin and downstream exactly as received. No reordering, no change.
- State. None.
- Ceiling. Not applicable: it does no work. It is also the only channel, so the ceiling of the SPI has never been tested with a real transport.
- On failure. It cannot fail. It also provides no durability, no remote delivery and no extra back-pressure.
Default Wiring
Spring configuration A tiny Spring configuration that registers the direct provider unless another provider bean exists.-
A configuration class declares one bean of the provider type, marked
@ConditionalOnMissingBean. If any other provider bean is already registered when Spring evaluates the condition, the default is not created. -
The class is found by normal component scanning: the application class is in the package
com.infenia.yukta, which contains this package. The module has no auto-configuration registration file. -
To replace the channel, register your own
NodeMessageChannelProviderbean. A test in the module shows the default appears when none exists and is skipped when a custom one is added to the context first. - Condition checks depend on the order in which Spring processes configuration. The conditional annotation is designed for auto-configuration classes that Spring orders last. Here it is on an ordinary scanned class, so the outcome can depend on scan order.
- Guarantees. In the tested setups, exactly one provider bean exists: the custom one if present, otherwise the direct one.
- State. A Spring bean, created once per application context.
- Ceiling. One provider for the whole server; per-node choice has to be made inside the provider.
- On failure. If the default and a custom provider both get registered, core’s constructor injection cannot choose one and the application fails to start with a clear error.
a NodeMessageChannelProvider @Bean
Build & Tests
dependencies, safety net The module’s two exported dependencies and its four test classes.-
The module exports Reactor core (for
Flux) and Spring Boot autoconfigure (for the conditional bean). Both are declared as ‘api’, so every module that depends on messaging sees them too. -
Core and plugin-api both declare messaging as an ‘api’ dependency. That is why every plugin sees
Messagewithout adding anything: plugin-api re-exports it. - One test class covers the record: accessors, withers, factories, immutability of maps and history, null handling, the 64 cap, and an oversized history passed to the constructor. Three small test classes cover the channel, the provider and the Spring configuration.
- There is no test of a custom channel with real behaviour, because none exists yet.
- Guarantees. The envelope rules in the Guarantees section are each pinned by a test.
- State. None.
- Ceiling. The module is tiny (about 7 main files); it cannot grow into a bottleneck by itself. Its weight is that everything depends on it, so a change here forces a rebuild and re-check of the whole repository.
- On failure. A breaking change to the interface fails compilation in core, plugin-api and every plugin, which is a loud and early signal.
:messaging:test (Gradle task)
Scenarios
Happy path
One message from creation, through one processor and out to its children. Use it first.A trigger creates a message
create(traceId, payload). The result has a random id, the current time, empty history and empty metadata. The trigger sets source port ‘default’.The outbound hook and the first stamp
outbound (a pass-through), then core appends the trigger’s node id to the history.Core routes along the edge
Inbound hook, plugin and withers
inbound into the plugin. The plugin makes a derived message with from and withMetadata. Id, trace and timestamp survive because it derived instead of creating.Outbound, stamp and fan-out
outbound; core appends the node id, checkpoints the message and fans out the same object to every child. Immutability makes sharing safe, but the payload object itself is shared by reference.Dead letter
How a failed message is marked and kept off the normal path, and one field that looks useful but is unused.A message fails all its retries
onError: ROUTE keeps failing for one message. Core’s policy gives up after the configured attempts.The envelope records why
withFailure(nodeId, 'PROCESSING_FAILED', exception text), adds headers failure.attempts, failure.exception and failure.time, and sets source port error. The original message and id are kept so the failure can be traced back.Routing keeps it off the happy path
error receives the message, typically going to a dead-letter terminal.What is not used
retryCount field, but nothing sets it. The count of attempts travels in the failure.attempts header instead. A reader who looks only at the field sees zero.Custom channel
What happens when a deployment replaces the default channel, and where the hook does not reach.A deployment registers its own provider
NodeMessageChannelProvider bean that returns an auditing channel for each node.Spring chooses
Core asks once per node
channelFor(nodeId, config) once per node and keeps the channel. The auditing channel’s inbound and outbound methods add an operator to each stream.Where it does not apply
Making it remote
Long chain or loop
What the 64-entry history cap means for long chains and loops.A message keeps travelling
The cap bites
Lineage questions go unanswered
Why the cap exists
Split and aggregate
How split children and aggregates are built, and why their lineage restarts.Splitter makes children
create and then given the parent’s trace id, correlation id (the parent’s id), sequence id and position 1/3, 2/3, 3/3.Each child is a new logical message
Aggregate merges
Nobody checks the sequence
Capabilities
What this module does, how, what it guarantees and where the guarantee stops.Envelope
Identity
Routing
Failure
Sequence
Channel
Lineage
Packaging
What is promised, and what is only carried
The module has no profiles or deployment modes. It is pure code with no storage, so what matters is which rules the envelope enforces and which fields are only labels. Nothing here survives a restart by itself: core checkpoints decide that.Enforced by the envelope
Each rule is applied in the record’s constructor or its withers and is covered by a test.Core rules built on the envelope
These behaviours live in core but define how fields are used.Carried but not enforced
These fields exist in the envelope. Nothing in core or the plugins acts on them. Do not assume they have an effect.Limits and defaults
Numbers a reader may need.Reference
Envelope field table
Every field, who sets it today, and who reads it. Verified against core and the plugins in the repository.Do and don’t for plugin authors
Short rules that follow from the guarantees.Architecture decisions
An immutable record envelope with withers
An immutable record envelope with withers
Enterprise-integration headers in the envelope
Enterprise-integration headers in the envelope
Node-level channel hook instead of a transport between nodes
Node-level channel hook instead of a transport between nodes
Plugin-replaceable default through a conditional bean
Plugin-replaceable default through a conditional bean
@ConditionalOnMissingBean in a scanned configuration; a custom bean wins.Consequences. Replacing needs one bean and no code change; the outcome depends on Spring ordering.Alternatives considered. Auto-configuration registration: the intended home for this condition. An explicit property selecting the provider: clear, but needs a name registry. An optional dependency injected with a default: simple, but hides which one is active.Revisit when. the first real custom provider is added, or two providers conflict at startup.Core stamps history, once per node
Core stamps history, once per node
The id is a logical id, kept across derivation
The id is a logical id, kept across derivation
from and withPayload keep the id; create makes a new one. Stores key by (execution, node, id).Consequences. Retries and enrichment keep one id; the id is not unique per node output.Alternatives considered. A new id at every node plus a parent id link: unique per hop but needs a lineage map and breaks ‘stable id for idempotency’. A separate lineage id: both properties, one more field.Revisit when. single-hop tracing or exactly-once keyed storage needs a unique id per output.Messaging as a tiny, dependency-light bottom module
Messaging as a tiny, dependency-light bottom module