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": "{{ $.fault_error }}", "step": "{{ $.fault_step }}" }```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].- There are no comments. A line starting with
#inside the front matter is a parse error. - There is no nesting. Every key is flat; grouped settings use a common prefix, as in
queue_max_attemptsandqueue_initial_delay_ms.
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 Steps and
Handling failures.
A document must contain at least one ## Step: section. A document consisting only of
## Fault: sections is rejected.
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. |
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. |
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 must be present for either to take effect; one on its own is ignored. See SOAP.
| 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.
What the second delivery of a key gets, per channel:
| Channel | The first execution has finished | The first is still running |
|---|---|---|
/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 journal: 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 journal 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; the second message is absorbed when the worker reaches it. 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.
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. |
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.
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 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.
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.
Do not combine correlation_close: with dedup_key: on the answering flow, under the default
retry policy. (Both are front-matter keys; this is about a flow that carries the two together.)
The combination compiles, publishes, and works on the first delivery of every key — and loses
answers on the first retry after a failure. The correlation is closed before the first step runs, so a
delivery that closed it and then failed — including one handled by ## Fault: — leaves its
deduplication row failed, and the partner’s retry is allowed to run: it is refused with 422,
again before the first step, where ## Fault: cannot see it. A corrected body does not help, and
correlation_close_required: false is not a way out — it tolerates a missing row, not a settled
one. A flow that needs both keys sets dedup_on_failure: absorb, which sidesteps the trap
entirely: the duplicate receives the recorded answer instead of running into the settled
correlation.
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, not re-read per execution — so changing configuration requires a restart. That is deliberate: if binding re-read the environment, the start-up check would have verified values the platform is no longer running on. Secrets behave the other way, because a rotated secret has to take effect without a restart and contradicts nothing.
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.
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 six 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.
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 is one exception. 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.
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: step 'forward-order': invalid configuration — frontmatter key `log_level` is declared more than once — the last one wins, silently. Keep one.This is checked for every front-matter key, not only the ones the platform reads.