Skip to content

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-order
tenant: acme
effects: [http_egress]
---
Accepts an order over HTTP, stamps it with a receipt time, and forwards it to the
order 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: forward
effects: [http_egress]
endpoint: https://orders.example.com/ingest
method: POST
## Fault: reject
response_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, and flowmarkdown_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_attempts and queue_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 closes
schedule: "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: # x has no value, since the comment starts where the value should be. Quote the value if the # belongs to it.
  • log_project:#ops starts its value with #, which must be quoted.
  • log_project: "ops"# x and effects: [http_egress]# x have 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:

Terminal window
$ nexus validate forward-order.flow.md
forward-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:orders

Queue 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: SubmitOrder
soap_namespace: urn:acme:orders

Deduplication

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 request
correlation_open: "{{ $.id }}" # → "REQ-8842"
# answering flow — in front matter
correlation_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-assessor
effects: [http_egress]
endpoint: https://assessor.example/assess
correlation_open: "{{ $.claimId }}"
correlation_state: {"claimId": "{{ $.claimId }}", "amount": "{{ $.amount }}"}
correlation_deadline_ms: 86400000
correlation_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: grpc
proto: orders@1.4.0
service: acme.orders.OrderService
method: SubmitOrder

service: 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: forward
effects: [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, so nexus validate catches 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:, a secret_read key), 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 and NEXUS_CONFIG_URL. config: [url] in one flow and config: [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 check gives 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:

Terminal window
$ nexus validate forward-order.flow.md
error: 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 implemented

Unknown 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:

Terminal window
$ nexus validate forward-order.flow.md
error: 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 setting

A 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:

Terminal window
$ nexus validate forward-order.flow.md
error: `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:

Terminal window
$ nexus validate forward-order.flow.md
error: 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.