The flow file
The complete anatomy of a .flow.md file: the front matter, the description, the step and fault
sections, and a reference for every front-matter key the platform accepts.
Shape
A flow file has three parts, in this order.
---flowmarkdown_version: "0.1"flow: forward-ordertenant: acmeeffects: [http_egress]---
Accepts an order over HTTP, stamps it with a receipt time, and forwards it to theorder service. Rejects an order without an id.
## Step: check```validate$.orderId != null | "orderId is required" | syntactic```
## Step: stamp```ntd{ "orderId": "{{ $.orderId }}", "receivedAt": "{{ date_format(now()) }}" }```
## Step: forwardeffects: [http_egress]endpoint: https://orders.example.com/ingestmethod: POST
## Fault: rejectresponse_status: 502```ntd{ "error": "the order could not be forwarded", "step": "{{ $.fault_step }}", "correlationId": "{{ $.fault_correlation_id }}" }```Front matter
The file must begin with --- on its own line, followed by key: value lines, closed by another
--- on its own line. Nothing may precede the opening delimiter, not even a blank line.
Three keys are required: flowmarkdown_version, flow and tenant. Everything else has a
default.
Rules for values:
- One key per line. A value cannot span lines.
- A value may be quoted with
"or'; the quotes are stripped. Quote a value that would otherwise be read as something else —schedule: "0 0 * * * *"is clearer quoted, andflowmarkdown_version: "0.1"avoids any question about the number. effects:is the only key that takes a list, and it must be bracketed:effects: [http_egress, secret_read].- A
#after a space or a tab, or at the start of a line, opens a comment; see Comments. - There is no nesting. Every key is flat; grouped settings use a common prefix, as in
queue_max_attemptsandqueue_initial_delay_ms.
Comments
Comments follow the YAML rule. A # at the start of a line, or after a space or a tab, opens a
comment that runs to the end of the line. The comment is not part of the value, and a line that
holds only a comment, or nothing, declares nothing:
max_message_bytes: 104857600 # 100 MiB# Nightly batch, after the ERP export closesschedule: "0 30 2 * * *"A # with no space or tab before it is text, as in proto: a#b or trigger_path: /in/#tmp, and
so is a # inside a quoted value: soap_namespace: "urn:x#y". A comment may follow a quoted
value or a list after a space or a tab. Three lines are refused, naming the key and never the
comment:
log_project: # xhas no value, since the comment starts where the value should be. Quote the value if the#belongs to it.log_project:#opsstarts its value with#, which must be quoted.log_project: "ops"# xandeffects: [http_egress]# xhave text after the value with no space or tab before the#.
Comments apply to the front matter only. Step metadata lines read the rest of the line as the value, as before.
Description
Everything between the closing --- and the first section header is free text. It is parsed,
carried with the document for editor tooling, and otherwise ignored — it does not reach the
compiled artifact and cannot be read from an expression. Use it to say what the flow is for.
Any line is allowed there except one beginning with ## Step: or ## Fault: , which ends the
description and starts the first section. Ordinary Markdown headings are fine, so
## Notes in the description stays in the description.
Sections
## Step: <name>## Fault: <name>Both headers are matched literally, including the single space after the colon. ## Step:forward
is not a step header — it is swallowed into the description, and the file then fails to parse
because it contains no steps.
## Step: sections form the main sequence and run in the order they appear.
## Fault: sections are collected separately, wherever they appear in the file, and run only when
a step in the main sequence fails. See Flow steps and step keys and
Handling failures.
A document must contain at least one ## Step: section. A document consisting only of
## Fault: sections is rejected.
Step names are unique in a flow, so a compile error that names a step is reported at the line of
that step’s header. In the flow above, with endpoint: misspelt on the forward step:
$ nexus validate forward-order.flow.mdforward-order.flow.md:21:1: error: step 'forward': unknown key `endpont` — did you mean `endpoint`?error: 1 validation error(s)The form is described under nexus validate.
Front-matter reference
Thirty-two keys are recognised.
Identity
| Key | Type | Default | Meaning |
|---|---|---|---|
flowmarkdown_version |
string | — | Required. The language version this file is written against. The only accepted values are "0.0" and "0.1"; anything else is rejected at compile time. |
flow |
string | — | Required. The flow’s name. Together with tenant it forms the flow’s address, including its URL path. A path segment: see the rule below. |
tenant |
string | — | Required. The owning tenant. Not overridable at deploy time. Same rule as flow. |
effects |
list | [] |
The effects any step in this flow is permitted to declare. A step declaring an effect absent from this list is a compile error. See The effect catalog. |
The rule for a name that becomes a path
flow, tenant, a step’s queue: target and correlation_timeout_flow: are all spliced verbatim
into two places the platform never escapes: the URL (/flows/{tenant}/{name}/run) and the queue
directory on disk. They share one rule, and it is checked at compile time, so nexus validate
reports a violation:
- lowercase ASCII letters, digits,
-and_only; - the first character is a letter or a digit (so a name never reads as a command-line flag);
- 1 to 128 bytes, no surrounding whitespace.
No dots (acme.orders.intake is refused — write acme-orders-intake), no uppercase (Foo and foo would be
two artifacts in the registry and one directory on a case-insensitive file system), no /.
The refusal names the key, the value and the character at fault. It also says what renaming costs:
a flow republished under a new name has a new URL for every caller and a new queue directory, so
messages still queued under the old name must drain first. The HTTP door applies the same rule and
answers 400 to any segment the compiler would refuse — see Conventions.
Invocation
| Key | Type | Default | Meaning |
|---|---|---|---|
schedule |
string | none | A six-field cron expression — sec min hour dom mon dow. When set, the server runs the flow on every tick with {"triggered_at": "<iso8601>"} as the message. |
required_scope |
string | none | An API key scope a caller must hold to reach this flow’s endpoints. Without it, any valid key for the tenant may call the flow. See Authentication. |
max_message_bytes |
integer | platform default, 1 MiB | Ceiling on one message body at this flow’s access points. 0 is a parse error, not “unlimited”. See Limits. |
max_concurrent_executions |
integer, 1 to 4294967295 |
absent: no per-flow limit | Executions of this flow running at once on one engine, every door counted together. The smaller of it and the engine’s max_concurrent_flow_executions binds. A call to /run, a declared route or the gRPC door counts from before its body is read, so a body still being uploaded holds a slot; a slow upload is cut only by the front proxy’s body-read timeout. /enqueue takes no slot: the message counts when the queue worker delivers it. Any other value is refused at publish with max_concurrent_executions must be a whole number from 1 to 4294967295, got '<value>'. Over the limit, see A flow’s own limit. |
body |
opaque |
absent: the body is parsed | body: opaque relays the body unread: the flow receives the request’s bytes, each http_egress step sends them and takes the answer’s bytes, and the caller gets the last answer’s bytes. Such a flow has only http_egress steps, no fence, no ## Fault and no expression that reads $. A parsed flow whose max_message_bytes is above the installation’s inline_payload_max_bytes is refused at deploy: it declares body: opaque, lowers the ceiling, or has the operator raise inline_payload_max_bytes to the ceiling or above. A gRPC flow cannot declare body: opaque, so only the last two apply to it. See Relaying a body unread. |
routes |
list of METHOD /template |
none | The paths and methods the flow answers at /t/{tenant}<template>, for example [GET /orders/{id}, POST /orders]. The flow is then served at its routes only, not at /run, /enqueue, its metadata or its WSDL. See Declared routes. |
serve_by_name |
boolean | false |
Only with routes:: true also serves the flow by its name, at /run, /enqueue, its metadata and its WSDL. Refused on a flow without routes:; required on a trigger: jms or trigger: vfs flow that declares routes:. See Served by name. |
schedule: "0 */15 * * * *"required_scope: run:ordersQueue retries
These apply to messages submitted to /enqueue, not to synchronous runs. See
Queues, retries and the DLQ.
| Key | Type | Default | Meaning |
|---|---|---|---|
queue_max_attempts |
integer | 3 |
Delivery attempts before the message goes to the dead-letter queue. |
queue_initial_delay_ms |
integer | 1000 |
Delay before the first retry. |
queue_backoff_factor |
number | 2.0 |
Multiplier applied to the delay after each failed attempt. Must be at least 1.0. |
queue_max_delay_ms |
integer | 60000 |
Upper bound on the delay, however many times the factor has been applied. |
queue_retention_hours |
integer, at least 1 |
none | The longest a queued message of this flow may wait, in whole hours. 0 is refused at publish. When set, it overrides the installation’s term (queue_retention_hours in system_config, see Capacity settings). A message older than the term is not delivered: it moves to the DLQ with the reason expired, and stays there until it is replayed or discarded. |
Logging
See Logging and audit.
| Key | Type | Default | Meaning |
|---|---|---|---|
log_level |
enum | event_only |
One of event_only, payload_trimmed, full. Controls how much of the message reaches the log. |
log_payload_max_bytes |
integer | 4096 |
Byte budget per logged payload when log_level: payload_trimmed. |
log_extra_mask_fields |
list | [] |
Additional field names to mask, on top of the always-on set. Written as a comma-separated list, brackets optional: log_extra_mask_fields: [iban, national_id]. |
log_project |
string | none | Free-text classification carried on every log entry for this flow. |
log_scenario |
string | none | A second free-text classification, narrower than log_project. |
SOAP
Both keys or neither: one on its own, an empty value, an operation that is not an XML name without a colon (NCName) or a namespace that is not an absolute URI is refused at publication. See SOAP and WSDL.
| Key | Type | Default | Meaning |
|---|---|---|---|
soap_operation |
string | none | The operation name published in the flow’s WSDL. |
soap_namespace |
string | none | The target namespace published in the flow’s WSDL. |
soap_operation: SubmitOrdersoap_namespace: urn:acme:ordersDeduplication
Deduplication is opt-in and live: a flow that declares dedup_key: claims the key before it
runs and settles the claim afterwards, on all four channels — /run, the gRPC door, the queue and
cron. A flow
that declares nothing is unaffected.
| Key | Type | Default | Meaning |
|---|---|---|---|
dedup_key |
template string | none | The expression identifying a message. Written like an endpoint: — literal text with {{ ... }} interpolations. Must not be empty. Surrounding whitespace is trimmed. |
dedup_scope |
enum | message |
One of message, singleton. What the key identifies: one message, or the flow itself. |
dedup_window_hours |
integer | none | How long a key is remembered. Must be between 1 and 8760. |
dedup_on_failure |
enum | retry |
One of retry, absorb. |
dedup_ack_mode |
enum | sync |
One of sync, durable. |
dedup_memoize_response |
boolean | false |
true or false. |
What is normalised, and what is not. Whitespace around the key is trimmed — both around the
expression you write and around the value it evaluates to — so " {{ $.id }} " and "{{ $.id }}"
are the same key. Nothing else is touched: interior whitespace stays part of the key, and ABC and
abc are two keys, not one. The asymmetry is deliberate. Merging two distinct keys drops a
legitimate message as a duplicate and cannot be undone; keeping two keys apart that could have been
one costs nothing.
A key that does not vary with the message is rejected. dedup_key: partner-notifications produces the
same key for every message, so the first delivery runs and everything after it inside the window is
treated as a replay — deduplication that looks configured and is not. Interpolating is not enough:
dedup_key: "{{ ctx.tenant }}" reads a variable and still yields one key for all of a flow’s
traffic. What varies with the message is the message itself — any $.... path — and the four request
facts ctx.correlation_id, ctx.headers, ctx.soap_action, ctx.soap_version. Everything else is
invariant, including ctx.<anything else>, which is an absent variable rendering as nothing. If an
invariant key is what you want — a job allowed to run at most once per window — say so with
dedup_scope: singleton, which accepts it. The opposite pairing is rejected too: dedup_scope: singleton with a key that varies per message contradicts itself.
A key that never repeats is rejected regardless of dedup_scope:. {{ uuid() }} and {{ now() }}
produce a fresh value every time, so nothing is ever recognised as a duplicate and the store fills
with rows that will never match. Those two functions are the whole set — every other builtin is a
pure function of its arguments, so it is as variable as they are and no more. No declaration disarms
this one, because there is no configuration under which a never-repeating key deduplicates anything.
So the rejections at publication are: any companion key without a dedup_key; an empty dedup_key
(which singleton does not excuse); a malformed template; a key with a nondeterministic part; an
invariant key without dedup_scope: singleton, and a per-message key with it; and a dedup_key
declared on a flow that is only ever included into another one. All of them are reported identically
by nexus validate, nexus compile and nexus deploy — a rule that only one of the three enforces
is a rule you find out about at the wrong moment. An editor or a pre-commit hook wired to validate
alone sees the same refusals as the deploy does.
One rule is left to execution, because it cannot be decided from the source: a part of the key
that renders to nothing. dedup_key: "notif-{{ $.id }}" on a message with no id would produce
notif- — one key shared by every message missing that field. Instead the delivery is refused,
naming which interpolated part came out empty, before the claim is made and on all four channels.
A key that cannot be read from the message at all is refused the same way: {{ $.order.id }} over
a body that is an array, or where order is a string. The caller gets 400 with “this message
does not carry the field this flow deduplicates on”, and nothing is claimed and nothing runs.
What the second delivery of a key gets, per channel, while the key is remembered:
| Channel | An earlier execution of the key finished without failing, or failed under dedup_on_failure: absorb |
An earlier execution still holds its claim on the key |
|---|---|---|
/run |
The recorded status, plus X-Nexus-Idempotent-Replay: true. The first answer’s body only with dedup_memoize_response: true |
409 with Retry-After, and no replay header — nothing was replayed |
| gRPC | The memoised answer, or already_exists when there is none to give |
aborted — gRPC’s code for “concurrency conflict, retry higher up” |
| queue | A silent ack and a dedup_replayed event |
Deferred, without consuming an attempt |
| cron | The tick is skipped, dedup_replayed |
The tick is skipped, dedup_held_back |
There is a third replay case on both request channels: the body was asked for and was over
dedup_max_response_bytes, so it could not be kept. /run answers 409 with the marker
X-Nexus-Idempotent-Replay: unavailable rather than the recorded status, because a 200 with an
empty body would let a caller read success where the platform cannot show what succeeded. gRPC says
already_exists with its own sentence — the operator action there is to raise the ceiling or stop
memoising, not to start memoising.
The replay headers. A replayed answer on /run carries two of them.
X-Nexus-Idempotent-Replay says the answer comes from the record, not from an execution — true
for a replay, unavailable for the oversized case above. X-Nexus-First-Correlation-Id is the way
back into the structured log: it names the correlation ID of the delivery that opened the row, which
is not necessarily the one whose body you are holding. A key can go through several deliveries
inside one window — under the default dedup_on_failure: retry, a failure lets the next delivery
run, and the recorded answer may come from that later attempt — while the row keeps the correlation
ID of its first delivery throughout. That ID answers when did this message first arrive, which is
where an idempotency investigation starts. Expect the consequence while diagnosing: follow the
header from a replayed 200 into the log and you can land on a delivery that itself ended
500. The opener failed, a later attempt succeeded, and the recorded success replays under the
opener’s ID — the header doing what it says, not a mislabelled answer.
Three consequences worth planning for.
/enqueue does not claim — the worker does. So two /enqueue calls with the same key both get
202, and the worker claims the key when it reaches each message: the second is acknowledged
without running once the first finished without failing, held back while the first still holds
its claim, and run if the first failed, unless the flow sets dedup_on_failure: absorb. 202
never promised execution, only enrolment.
A replay with dedup_memoize_response: false returns the recorded status with an empty body —
not {}, not the first answer: zero bytes. A normally written client will call resp.json() on it
and throw, which in that client’s log looks like a corrupt answer rather than deduplication working.
Either set dedup_memoize_response: true (the body is then held in the row, under
dedup_max_response_bytes), or teach the client that X-Nexus-Idempotent-Replay: true means “done,
stop reading”.
A delivery that ends in ## Fault: is recorded as a failure, even though the caller received a
composed answer with a status of the flow’s choosing. The row answers one question — may a duplicate
of this key run? — and the main sequence did not reach its end, so the answer is yes: the next
delivery of the same key runs. That is what makes a corrected retry possible; without it, a
rejected message would keep receiving its own rejection for the rest of the window.
If a contract needs the opposite — one stable answer per key, failure included — set
dedup_on_failure: absorb. The recorded body is kept either way; absorb is what makes it replay.
Note that under the default retry the duplicate re-runs the whole flow, including any effect the
first delivery already performed before it failed — and including the effects of the ## Fault:
sequence itself. Fault steps are ordinary steps and may carry effects:, so a handler that notifies,
compensates or posts to a dead-letter queue fires once per delivery, not once per key. A flow that
carries both keys and needs the repeats bounded needs a rate limit on the API key that calls it.
The end-to-end pattern that makes redelivery safe — the message’s key travelling to the destination, and “delivered” defined by the flow rather than assumed — is on the queue page, under What you must do to be safe under redelivery.
A delivery that loses its claim stops before its next step. The claim lasts
flow_lease_ttl_secs and is renewed between steps. If an operator releases it, or the platform
cannot confirm it for two thirds of that time, the delivery stops before its next step, answers
503, and records nothing; the step in progress runs to its end. See A lost claim skips the
handler.
ctx.headers is an empty object on the queue and on cron. A key that reads a header off it
works on /run and refuses every message elsewhere — loudly, not silently: the key renders empty,
the delivery is refused before the claim, and on the queue the message ends in the dead-letter
queue. Read the key from the message body; if the
identity genuinely lives in a header, expose the flow on /run only.
Correlation
A different problem from deduplication, and a separate set of keys. Deduplication asks have I seen this message before. Correlation asks is this the answer to something I asked for — you send a request to a partner that accepts it now and answers later, in a separate delivery, over a channel of its own choosing, minutes or hours on.
Two flows are involved, and they do not call each other:
- The asking flow sends the request and opens a correlation: it records “I am waiting for
an answer identified by K”, stashes the context the answer will need — its
correlation_state— and finishes. It does not block. - The answering flow is a separate flow, published on whatever endpoint the partner calls back on. It closes the correlation: the platform matches the incoming answer to the open row by K, hands back the stashed context, and runs the flow.
Opt-in like deduplication: a flow that declares none of the correlation_* keys below does no
correlation at all — nothing is recorded, nothing waits.
The key is an expression, written on both sides, that must produce the same string.
correlation_open (in the asking flow) and correlation_close (in the answering flow) are each
evaluated against the message in hand. They live in two different files and know nothing about each
other; the only thing that links a request to its answer is that both expressions resolve to the
same value.
The partner decides what its callback looks like. Your job is to write correlation_close so it
pulls out the same identifier your correlation_open put in. If the request carries
{"id": "REQ-8842", …} and the callback carries {"ref": "REQ-8842", "verdict": …}:
# asking flow — on the step that sends the requestcorrelation_open: "{{ $.id }}" # → "REQ-8842"
# answering flow — in front mattercorrelation_close: "{{ $.ref }}" # → "REQ-8842"Both resolve to REQ-8842 for that exchange, so the platform files the answer against the open
request. Get the field wrong on either side and the values never meet: the answer is treated as
uncorrelated (refused, unless correlation_close_required: false), and the request waits out its
deadline.
Like a dedup key, it must be deterministic — a value taken from the message, never now() or
uuid(), which would never match.
The asking flow opens the wait on a step, with three keys that go together:
## Step: ask-assessoreffects: [http_egress]endpoint: https://assessor.example/assesscorrelation_open: "{{ $.claimId }}"correlation_state: {"claimId": "{{ $.claimId }}", "amount": "{{ $.amount }}"}correlation_deadline_ms: 86400000correlation_timeout_flow: escalate-claim| Key | Required | Type | Default | Meaning |
|---|---|---|---|---|
correlation_open |
opt-in | template string | none | The expression that produces the correlation key from the request. Must resolve to the same string as the answering flow’s correlation_close. |
correlation_state |
with correlation_open |
object of string templates | none | An object, saved now with its templates resolved, handed back when the answer arrives. Every value is a string template — a bare number does not parse. {} is legal — it says “nothing to carry”, which is different from forgetting to say. A value that evaluates to an object or an array holding an XML element is refused when the step runs, and the error names the field; carry one element’s text with string(descendants($, 'Item')[0]), or the texts of all with join(descendants($, 'Item'), '|'). |
correlation_deadline_ms |
with correlation_open |
integer (ms) | none | How long to wait. There is no default: a wait with no end is a leak, not a policy. |
correlation_timeout_flow |
no | flow name (the rule) | none | A flow to run if the deadline passes first. Absent means the row is simply marked timed_out, which is a legitimate choice. |
A companion key without correlation_open: is a compile error, and so is correlation_open:
without its two required companions. That pairing is what catches a misspelt key instead of letting
it be ignored.
A correlation key is one-shot. Opening a correlation on a key that already has a row — open,
closed, or timed_out — is refused with 409, and the existing row is left untouched: a late or
duplicate reply could still arrive on it. The key is not free again until its row is purged, which
is flow_state_retention_hours (an installation setting, default 96 h) past the deadline — see
flow-state window. Make correlation_open:
produce a value that is genuinely unique per request; "{{ ctx.correlation_id }}" is, unless the
caller pins X-Correlation-ID to a constant.
A flow cannot write ctx.correlation_id unchecked. It is the platform’s trace id. save_body: correlation_id is refused at compile time, in a nested or fault step too, and so is a
secret_read step with key: correlation_id, or named
correlation-id. nexus validate and publishing give the same verdict.
restore_body: correlation_id only reads the id and stays legal. To carry a value of your own,
save it under another name and send it on in a header, a body field or correlation_state:. The
refusal applies when a flow is published. A flow published earlier keeps running. There, a
save_body: step sets the id only to a value inside
the rule; a secret_read step sets nothing.
Anything else is not written, the id stays as it was, and the server log gets a WARN line,
flow-written correlation id not adopted, without the value.
correlation_state is the note the asking flow leaves itself. The answer arrives on a
separate delivery — a different flow, on the partner’s callback, with no access to the message
that opened the wait. Whatever the answering flow will need from the original request goes here, as
a small object of string templates resolved at open time, and comes back as ctx.correlated.
The values are stored for the retention window, so a state template never reads a credential:
ctx.oauth2_token, ctx.headers.authorization or a name a secret_read step writes. The refusal
covers the library flows the file reaches, and then names the library.
The answering flow declares the other side in front matter:
| Key | Required | Type | Default | Meaning |
|---|---|---|---|---|
correlation_close |
opt-in | template string | none | The expression that produces the same key from the arriving answer. This flow is the door the answer comes in by. |
correlation_close_required |
no | boolean | true |
An answer with no open correlation is refused, not run silently. Set false to let it through. |
Reading the answer. When the answering flow runs, the object from correlation_state is
available as ctx.correlated, and ctx.correlated_late is a boolean — true if the answer
arrived after the deadline had already lapsed.
## Step: apply-verdict```ntd{ "claimId": "{{ ctx.correlated.claimId }}", "amount": "{{ ctx.correlated.amount }}", "verdict": "{{ $.verdict }}", "late": {{ ctx.correlated_late }}}```An unmatched answer, under the default correlation_close_required: true, is refused with 422
before any step runs. ## Fault: never sees it, and retrying does not help — 422, not 409,
because no resend of the same reply becomes processable. It is recorded as a refusal, not a
fault, so it does not move the flow’s error rate. The open request row, if one exists under the
right key, is untouched and waits out its own deadline. With correlation_close_required: false
the same case becomes an ordinary run with ctx.correlated left null — for a flow that
genuinely handles replies nobody asked for.
A reply the key is not readable from is refused with 422 as well, before any step runs.
correlation_close: "{{ $.ref }}" over a reply that is an array, or {{ $.case.id }} where case
is a string, cannot form the key, whatever correlation_close_required: says. The caller gets
“this message does not carry the field this flow correlates on”, no correlation is closed and
nothing runs; a correlation open under the key the reply meant stays open.
The deadline is reconciled by a background pass — every 60 seconds while nexus serve is up,
with the first pass at startup. The 60 s is how late you learn, not the deadline’s precision:
deadline_ms is compared against the clock, not the tick. The row becomes timed_out, and if
correlation_timeout_flow: is declared, that flow is enqueued with the correlation_state as
its message — it reads the stashed fields as $, not ctx.correlated, since there is no answer
here. Being enqueued, it needs a queue worker draining that tenant to run at all.
A deadline passing is not a fault. The Stats page counts fault as the flow’s error rate, and
a silent partner is not a defect of your flow. It gets its own event, correlation_timed_out.
correlation_state is bounded by correlation_state_max_bytes — an installation-wide
system_config setting, 64 KiB by default, not a front-matter key. It is state the platform holds
on your behalf, not a place to park the message. An open whose serialised state is over the ceiling
is refused, so the wait is never started.
Open and timed-out rows are visible only with nexus state list
— there is no page for them in the Management UI. The UI’s Logs page shows the events around a
correlation instead — correlation_opened, correlation_closed, correlation_closed_late,
correlation_timed_out — which tells you whether one resolved, but not what is waiting right now.
A failed delivery reopens the correlation it closed, on a flow that declares both
correlation_close: and dedup_key:. The correlation is closed before the first step runs. When
the delivery then fails, whether ## Fault: handled it or not, the platform puts the correlation
back as it found it before recording the failure. Under the default dedup_on_failure: retry, the
partner’s retry of the same reply then runs its steps with the same ctx.correlated. Under
dedup_on_failure: absorb, the retry receives the recorded failure and closes nothing, so
the correlation stays open until a reply under another dedup key closes it or its deadline passes
and its compensation flow starts. A reply that arrived late is put back as timed out, not as
open, so the compensation flow that already ran for it does not run a second time. The
correlation_closed event of the failed delivery stays in the log; nexus state list shows the
correlation open again. Two cases stay closed: on a flow without dedup_key:, a failed delivery
leaves the correlation closed, and the retry is refused with 422 as already settled; and a
process that stops before the delivery ends leaves it closed too.
Inbound gRPC
Four keys, all four or none, saying which gRPC method this flow answers. A partial declaration — in either direction — is refused at compile time.
| Key | Type | Meaning |
|---|---|---|
inbound |
enum | The only accepted value is grpc. Its absence is what keeps a flow out of the routing table, so there is no default |
proto |
string | name@version, the same form as on an egress step |
service |
string | Fully-qualified service name |
method |
string | The method this flow answers |
inbound: grpcproto: orders@1.4.0service: acme.orders.OrderServicemethod: SubmitOrderservice: and method: in front matter name the service the flow answers; the same two keys on
a step belong to grpc_egress and name the service the flow calls. See
Exposing a service for the door itself.
Environment configuration
A flow does not read the environment directly. It declares the names it needs, and the platform binds them:
config: [BILLING_URL, PARTNER_ENDPOINT]| Key | Type | Default | Meaning |
|---|---|---|---|
config |
list | [] |
The environment configuration this flow requires, read in expressions as ctx.<KEY>. Brackets are optional — config: BILLING_URL, PARTNER_ENDPOINT parses the same. A name that is not a valid identifier is refused at parse time. |
Each name is fed by NEXUS_CONFIG_<KEY> in the server’s environment — the same source as secrets,
but a distinct notion: an endpoint is not a secret, it is not masked in the log, and it costs no
step. Reading it is an ordinary expression:
## Step: forwardeffects: [http_egress]endpoint: "{{ ctx.BILLING_URL }}/submissions"Four things follow, and each is the point of declaring at all:
- A
{{ ctx.X }}naming something nothing declares is a compile error, sonexus validatecatches it before publication. Three families of name pass: the ones the platform writes (ctx.tenant,ctx.correlation_id,ctx.headers,ctx.HTTP_SC, …), the ones a step writes (save_body:, asecret_readkey), and the ones you declare here. A misspelled name used to evaluate to null and travel on as an empty field. - A declaration that shadows a name the platform writes, or one a step already writes, is
refused — with both sites named.
config: [tenant]does not quietly win. - Two names in one declaration that would be fed by the same variable are refused — names are
upper-cased to find their variable, so
config: [url, URL](or an exact duplicate) cannot be configured apart and fails compilation naming both names andNEXUS_CONFIG_URL.config: [url]in one flow andconfig: [URL]in another legitimately share one installation value: each declaration is checked on its own. - The server refuses to start if a published flow declares a key the environment does not
supply, naming the tenant, the flow, the key and the variable. At start-up, not at the first
message.
nexus config checkgives the same verdict without starting anything.
The value is captured once at start-up, and the start-up check verifies exactly the values the flow then runs with. Secrets are read on every execution, but from the process environment, which does not change after start-up. So changing a configuration value or rotating a secret both require a restart.
Set-but-empty and absent are different. An empty prefix is legitimate, so empty starts and is
reported at INFO; absent is a refusal.
The response to the caller
| Key | Type | Default | Meaning |
|---|---|---|---|
response_headers |
list | [] |
Headers from an http_egress backend response that are allowed to reach the caller. [] is the same as absent. |
response_headers: [etag, retry-after]Nothing from a backend response reaches the caller unless it is on this list. The pass-through
happens where the response is read, so it behaves identically on 2xx and non-2xx — which is
what lets a ## Fault: return the Retry-After of a 429. A listed header the backend did not
send is simply omitted; it is an absent value, not an error and not an empty header.
This is not a separate mechanism from a step’s response_header_<name>: — both write the same
set, so the last writer wins per header name, in execution order. See
Dictating the response.
x-correlation-id is not a header a flow can send to the caller: the platform writes it on every
answer, and publishing refuses it in response_headers:, in a step’s response_header_x_correlation_id:
and in the same key of a ## Fault: section. It stays legal on an egress request. See
the ID in the answer.
Three families of name are refused at compile time: the headers the platform protects
(host, content-length, transfer-encoding, connection, keep-alive, upgrade,
proxy-connection, te, trailer), the seven credentials that are withheld from flows in the first
place — a name that never reaches a flow cannot come back out — and anything in the x-nexus-
namespace, which is the platform’s own vocabulary for statements about delivery. A name that is not
a valid HTTP token is refused earlier still, at parse time.
traceparent and tracestate are protected too, in both directions: refused in an egress
headers:, in gRPC metadata: and in a step’s response_header_<name>:. The engine
writes them on every egress from the span it alone knows (see
Spans over OTLP).
The status is not passed through: a non-2xx from a backend stays an EffectFailed, which is
what makes queue retries work. To answer with the backend’s status, declare it —
response_status: "{{ ctx.HTTP_SC }}". The gRPC door has no HTTP response headers, so
response_headers: on a flow routed there reaches nowhere.
Replay
The key replay: from_log was withdrawn. Deterministic replay from the audit log was never
implemented, and the key parsed and validated without doing anything. It is now a parse error that
says so — rather than an unknown key, which would have been ignored silently:
$ nexus validate forward-order.flow.mderror: parse failed with 1 error(s): - 'replay' is not a valid frontmatter key (withdrawn in T-0051): deterministic replay from the audit log was never implementedUnknown keys
An unrecognised front-matter key is accepted and ignored — connectors put their own configuration in this same block, and the platform parser has to pass over it.
That would make a typo silent, so there are two exceptions. If an unknown key is within two edits of a key the platform knows, and is at least four characters long, it is a parse error:
$ nexus validate forward-order.flow.mderror: parse failed with 1 error(s): - unknown frontmatter key 'log_levell' — hint: did you mean `log_level`? An unrecognised key is ignored, so a typo here silently drops the settingA key further away than that passes without comment. queue_maxattempts is caught;
max_queue_attempts is not.
The second is the key trigger, which is read at publication. nexus validate, nexus compile
and nexus deploy refuse a flow whose last trigger is not exactly jms or vfs, and
nexus deploy stores nothing. With trigger: kafka:
$ nexus validate forward-order.flow.mderror: `trigger` names no connector: it must be one of `jms`, `vfs`error: 1 validation error(s)When the value is within two edits of a connector name, the message ends with a hint:
trigger: jsm gives “; did you mean jms?”. This hint compares the value with the connector
names; it is not the key-name check above. Every other connector key, the other trigger* keys
included, is still passed over by the platform and read only by the connector, at its next start
(see VFS and JMS).
Duplicate keys
Declaring the same key twice is an error. The second declaration would win, and it would win without saying so:
$ nexus validate forward-order.flow.mderror: invalid configuration — frontmatter key `log_level` is declared more than once — the last one wins, silently. Keep one.error: 1 validation error(s)This is checked for every front-matter key, not only the ones the platform reads.