Skip to content

Logging and audit

Two separate records. The structured log answers “what happened to message X” and is configured per flow. The audit trail is an append-only hash chain you can verify. Then the management UI, which reads both.

The structured log

Every event is written to two places, always both:

Sink What it is for
nexus-log.db, table flow_log Querying. Indexed by (tenant, flow, ts) and by correlation_id. This is what the UI reads.
logs/nexus-log-YYYY-MM-DD.<host>-<pid>.jsonl Shipping. One JSON object per line, one file per day and per engine process, appended. Read the glob nexus-log-*.jsonl and order by ts.

Both live under the state directory. See Server and configuration.

Those two are the only destinations. There is no export to an external collector; metrics are pulled from the metrics port (see Metrics). A collector that wants the log reads the daily files.

Events reach the writer through a bounded in-memory channel that holds 65,536 of them. If the channel is full the event is dropped. Every lost event is counted, and the loss is reported in the operational log at once and then at most once a minute, as a warning with two fields: dropped, the events lost since the previous warning, and dropped_total, all the events lost since the process started. The text is nexus-log channel full: L1 events dropped, or nexus-log writer stopped: L1 events dropped when the writer itself has stopped. No such line means no event was lost. The same count is also nexus_log_events_dropped_total on the metrics port. Logging never slows a flow down and never fails one, which is the right trade for the structured log and the wrong assumption to build billing on.

What a record contains

{
"ts": "2026-08-09T14:02:14.318Z",
"correlation_id": "3f8b1c42-9e70-4a51-b8d2-1c0e7a4f9b33",
"tenant": "acme",
"flow": "forward-order",
"event": "completed",
"channel": "http",
"duration_ms": 87,
"payload": { "status": "accepted", "orderId": "A-1" },
"project": "orders",
"scenario": "intake"
}
Field Always present Meaning
ts yes ISO 8601 UTC, milliseconds.
correlation_id yes See below.
tenant yes
flow yes
step no Step name. Omitted on a delivery’s own events; set on correlation_opened and on branch_refused, where it names the step.
event yes One of the kinds below.
channel yes Which door the message came in by: http, grpc, queue or cron.
duration_ms no Omitted when zero. Wall time of the execution, not of a single step.
payload no Masked, and trimmed at payload_trimmed. Absent unless the flow asks for it.
reason no On delivery_refused only: why nothing was delivered, one code from the list below.
error no The error text, on failure events, and the reason on branch_refused. Names the step, the path and the type — never a value from the message. See below.
attempt no Delivery attempt number, on the queue events.
project no From log_project.
scenario no From log_scenario.

Omitted fields are absent from the JSON Lines record and NULL in the database column.

The unit is a delivery, not an HTTP request

One delivery leaves a received plus exactly one verdict — completed, fault or delivery_refused. Which door it came in by is the channel column, not the kind of event: a scheduled run emits the same received and verdict as a call to /run, with channel set to cron. So counting deliveries means counting received, on any channel, and a flow that runs on a schedule is as visible as one that is called.

Event kinds

A delivery, on any channel:

Kind Emitted when
received A message arrives, before the flow is loaded or executed.
completed The execution finished and an answer is going out. Carries duration_ms and, if the flow asks for it, the output payload.
fault The execution failed. Carries error and, if the flow asks for it, the payload the execution failed on.

Refusals — three of them, three different operator actions, and none of them is a fault:

Kind Emitted when
access_denied The caller has no right to this flow: no credential, a revoked one, a missing scope, a flow outside the key’s allowlist, or a webhook signature that is absent (signature_missing) or wrong (signature_mismatch), the code in the error column.
handoff_refused The platform is at capacity. On a synchronous call (/run, the gRPC door) the caller is refused before the flow starts and nothing is kept: 503 with Retry-After: 1 over HTTP, resource_exhausted over gRPC. A scheduled tick is skipped. At the queue door the message is already durable in the queue’s log and the caller got 202; what was refused is starting a worker for it: the platform is at capacity, the queue’s worker lock could not be taken, or the queue’s worker ended on a failure, less than 5 seconds before or by 5 panics in a row. The event’s error says which and when the worker is started again.
delivery_refused The caller had the right, but there was nothing to deliver to, or the body could not be taken: the flow is not published, its artifact cannot be loaded, the body is over the size ceiling, nested too deep, malformed or unreadable, or the request cannot be identified. Also a scheduled tick skipped, or a queue held, because the tenant is disabled. Carries a reason code (below).

None of these counts toward the error rate on the Stats page, and none of them makes a flow look active. A port scanner would otherwise raise the error rate of flows it never touched. This holds for a body too: on /run, /enqueue and the gRPC door, a body over the size ceiling, an XML body nested too deep, a malformed JSON, XML or SOAP body and a body that could not be read each leave one delivery_refused, never a fault. The status and the answer the caller gets do not change.

The reason of a delivery_refused is one of these, and the event carries nothing of the refused body:

reason What was refused
body_over_ceiling The body is over the size ceiling (413).
body_read_failed The body could not be read to its end, for instance because the caller went away.
body_malformed The body is not valid JSON or well-formed XML.
json_not_i_json The JSON body is valid JSON but not I-JSON: a member name appears twice in one object, or a string holds a Unicode noncharacter (400).
xml_too_deep The XML body is nested past the depth ceiling.
soap_envelope_malformed The SOAP envelope could not be unwrapped.
body_refused The gRPC decoder refused the message before the flow, for instance over the ceiling.
flow_not_published No flow is published under that name. On the cron channel: the tick was skipped because the flow is not deployed, one event per run of skipped ticks.
served_at_routes_only The flow declares routes: and not serve_by_name, and the request came by its name, at /run or /enqueue (404).
artifact_unreadable The flow’s artifact cannot be loaded or fails its integrity check.
route_withdrawn The route the request matched is no longer declared.
inbound_not_declared The flow no longer declares the door the call came in by.
contract_unresolvable The gRPC contract the flow names cannot be resolved.
streaming_unsupported The gRPC method streams, and the platform serves unary calls only.
registry_unavailable The registry could not be read.
message_mapping_failed The gRPC message does not fit its descriptor.
message_unqueueable /enqueue refused a JSON body nested too deeply for the queue to read back (400); nothing was written.
queue_write_failed /enqueue could not write the message to the queue.
deliver_rejected The delivery was refused before the flow ran, for instance an empty dedup key.
encoded_opaque_body /run, /enqueue or a declared route of a body: opaque flow refused a body with a Content-Encoding (415); nothing was run or queued.
tenant_disabled The tenant is not registered or is disabled. On cron the tick was skipped; on queue the queue holds its messages until the tenant is enabled. One event per skipped tick and one per queue hold.

A reason is stable once released. A row written before this column existed has none.

Deduplication — only on flows that declare a dedup_key::

Kind Emitted when
dedup_replayed A delivery of a key did not run because the record already holds its outcome: an earlier execution of the key had finished without failing, or had failed under dedup_on_failure: absorb.
dedup_held_back A delivery of a key was held back and did not run: an earlier delivery still held its claim on the key, or the key’s recorded state is not one this build understands.

Correlation — only on flows that open or close one:

Kind Emitted when
correlation_opened A step declared correlation_open: and the wait was recorded, with its deadline.
correlation_closed The awaited answer arrived in time and the waiting state was rehydrated.
correlation_closed_late The answer arrived after the deadline had already been reconciled.
correlation_timed_out The deadline passed with no answer. Not a fault: a silent partner is not a defect of the flow.

Detached branches — only on flows with a fork step:

Kind Emitted when
branch_refused A fork branch was not started: the execution budget was full (max_concurrent_flow_executions), or the operating system refused the thread. The delivery went on and answered as if the branch had started, so its caller was not told. The branch’s work did not happen, and nothing retries it. Carries the fork step in step, the reason in error, and the correlation ID of the delivery that forked. Not a fault, and not counted in the error rate.

Find the delivery by that correlation ID and decide whether to send the message again. If the reason is the execution budget, the platform was at capacity: raise max_concurrent_flow_executions or reduce the load.

Queue:

Kind Emitted when
queue_enqueued A message was written to the queue by /enqueue.
queue_processed A queue worker ran a message successfully. Carries duration_ms, attempt and, if asked for, the output payload.
queue_filtered A condition fence dropped the message. Acknowledged, not retried, no payload recorded.
queue_failed A delivery attempt failed and another is scheduled. Carries error, attempt and, if asked for, the payload.
queue_circuit_deferred A delivery reached an open circuit before any effect ran and was put back without spending an attempt (see Circuit breaker). Written once per message. Carries error and attempt.
dlq_moved A message was moved to the dead-letter queue. Carries error, attempt and, if asked for, the payload.

Connectors and certificates:

Kind Emitted when
connector_received A connector took a message off its source and is forwarding it.
connector_oversized A connector skipped a message for exceeding its size limit.
cert_expiring The maintenance sweep found a registered certificate inside its warning window.
cert_expired The sweep found one already past its validity.
api_key_expiring The sweep found a non-revoked API key inside the same warning window. The record names the key by its label (step) and its id; never its hash, scopes or flows.
api_key_expired The sweep found a non-revoked API key already past its expiry, or one whose expiry date cannot be read.

The audit chain:

Kind Emitted when
audit.checkpoint A checkpoint of a tenant’s audit chain was taken: after an administrative act, after every hundredth entry of the tenant, when nexus serve starts, and when nexus audit checkpoint is run. The payload is the checkpoint itself. See Anchoring the tail.

This is the one kind whose name has a dot, because it is also the name a reader of the file searches for. It is not an entry of the audit chain, and it is not a delivery: it has no flow, no channel and no outcome.

received is emitted before the artifact is loaded, so it is the one event that carries neither project nor scenario — the flow’s configuration is not known yet.

Every kind in this table is written by something. A kind with a filter in the UI and no producer is worse than a missing feature: you get zero rows and cannot tell “no traffic” from “this is never recorded”. Three kinds lived that way for months and were removed; a test now refuses to let the list grow a fourth.

Metrics

nexus serve --metrics-port <n> opens a second listener that answers one route, GET /metrics, in the Prometheus text exposition format, with Content-Type: text/plain; version=0.0.4; charset=utf-8. Without the flag nothing listens. 0 asks the system for a port, announced at start as NexusFabric metrics listening on <addr>. The listener has no authentication; another path answers 404 and another method 405. A scrape writes nothing: no log event, no audit entry.

Family Type Labels Meaning
nexus_deliveries_total counter tenant, flow, channel, outcome Deliveries that reached a verdict. channel is http, queue, cron or grpc; outcome is completed, fault, dedup_replayed or dedup_held_back. A request refused before the flow ran is not counted.
nexus_delivery_duration_seconds histogram tenant, flow, channel Duration of the completed deliveries counted above. A fault’s duration is not observed.
nexus_queue_messages_total counter tenant, flow, outcome Messages the queue worker handled: processed, failed, filtered, dlq_moved.
nexus_queue_depth gauge tenant, flow Messages in a flow’s queue not yet acknowledged, read at the scrape.
nexus_dlq_depth gauge tenant, flow Messages in a flow’s dead-letter queue, read at the scrape.
nexus_certificate_expiry_days gauge tenant, name, kind Whole days until each registered certificate expires, read at the scrape; negative once it has. A certificate whose expiry cannot be read has no series.
nexus_log_events_dropped_total counter none Log events lost because the channel was full or the writer had stopped (see The structured log).
nexus_audit_append_failures_total counter none Appends to the audit chain that failed.
nexus_circuits_open gauge tenant, effect Circuits of that tenant in that handler that are open or probing, read at the scrape. effect is http_egress or grpc_egress.
nexus_circuit_transitions_total counter tenant, effect, to Circuit transitions in that handler: to="open" when a circuit opens or a probe fails, to="closed" when a probe succeeds.
nexus_trace_spans_dropped_total counter reason Spans lost on the way to the OTLP collector: reason is queue_full, export_failed or rejected (see Spans over OTLP). Its three series always print, 0 at start, also when no endpoint is set.

Every family prints its # HELP and # TYPE lines even before it has a series; the two counters without labels always print a value, 0 at start. A series appears only for a published flow that has done the thing it counts, so the number of series is bounded by the published flows. No label carries a correlation id, a step, a key, an error text or a payload. A tenant has circuit series from the first refused connection a handler counts for it, and keeps them, at 0 when nothing is open, until the restart. No label names a destination: the destination of an open circuit is in the warn log line written when it opens (circuit open — this destination is skipped until a probe is due).

The histogram buckets are fixed: 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, +Inf seconds, cumulative, with _sum in seconds and _count.

The counters start at zero when the process starts and are not kept across a restart; Prometheus reads the drop as a reset, so rate() and increase() stay correct.

Several engines. Each engine serves its own counters, for what it did. Scrape every engine and sum the counters across them. The gauges read the shared state directory, so every engine on one state directory reports the same queues and certificates: take the maximum across engines, not the sum. nexus_circuits_open is not read from that directory: each engine has its own circuits.

When the certificate registry cannot be read, the scrape answers 500 with the body metrics: certificate registry unreadable, so the target goes down in Prometheus instead of a series disappearing.

Four queries to start from:

# Fault rate per flow over five minutes
sum by (tenant, flow) (rate(nexus_deliveries_total{outcome="fault"}[5m]))
/ sum by (tenant, flow) (rate(nexus_deliveries_total[5m]))
# A dead-letter queue that is not empty
max by (tenant, flow) (nexus_dlq_depth) > 0
# A certificate that expires within 30 days
max by (tenant, name, kind) (nexus_certificate_expiry_days) < 30
# A circuit open on any engine
nexus_circuits_open > 0

Correlation IDs

One value tying every record of one message together.

Source Precedence
X-Correlation-ID request header first
X-Request-ID request header when X-Correlation-ID is absent or invalid
A generated UUID v4 when both are absent or invalid

A value you send is used only if, after spaces and tabs at either end are trimmed, it is 1 to 128 characters of A-Z a-z 0-9 . _ : -. A UUID, a W3C trace id, a traceparent value and a JMS ID: message id all fit. Any other value is not cut or cleaned up: it is replaced whole, and the request carries on, with no error, under the next valid source. /run, /enqueue, GET /flows/{tenant}/{name}, /connector/events and the gRPC door apply the same rule.

Each replaced value leaves one INFO line in the server log, under the target nexus_runtime::correlation_id, like this one:

INFO nexus_runtime::correlation_id: caller correlation id not adopted source="x-correlation-id" reason="outside_set" offered_bytes=29 correlation_id="5b1f…"

source is the header or field that held the value, reason is empty, too_long or outside_set, offered_bytes is its length as received, and correlation_id is the id the request is traced under. The value itself is written nowhere: not on this line, not in the event log, not in the audit chain. A flow called through /run can still read the header in ctx.headers['x-correlation-id'], unless the value holds a non-ASCII byte: such a header is left out of ctx.headers.

For a queued message the ID is stored in the queue frame, so queue_enqueued, every queue_failed, and the final queue_processed or dlq_moved all share it. A retry does not get a new ID. A dead-letter replay keeps the ID the message had. A cron tick gets a generated one, since there is no caller to take it from.

Where the caller finds it

The ID comes back on every answer: the X-Correlation-ID response header on the JSON and SOAP doors, /enqueue and /connector/events, and x-correlation-id in the response metadata of a gRPC call, on success and on every refusal, a gRPC refusal included (a Trailers-Only answer carries it in the one header block). It is the ID the journal and the log hold for that request, so a caller that quotes it lets you find every record of the delivery. A value you sent that fails the rule is never echoed: the answer carries the generated UUID. The cron scheduler and the queue workers answer nobody and return nothing; their ID is in the journal. A reverse proxy has to leave the header in place. A flow cannot set it, see the ID in the answer.

Reading it in a flow

It is available as ctx.correlation_id.

---
flowmarkdown_version: "0.1"
flow: forward-order
tenant: acme
effects: [http_egress]
---
## Step: forward
effects: [http_egress]
endpoint: https://orders.example.com/ingest
method: POST
headers: {"X-Correlation-ID": "{{ ctx.correlation_id }}"}

That passes the same ID downstream, so one trace spans both systems. It also works inside a template:

## Step: build-response
```ntd
{ "accepted": true, "trace": "{{ ctx.correlation_id }}" }
```

Log levels

One key per flow, log_level.

Value Records the event Records the payload
event_only yes no
payload_trimmed yes masked, then capped at log_payload_max_bytes
full yes masked, no cap

event_only is the default. There is no setting that stops a flow appearing in the log — every level records the event and its verdict; event_only just stops there, before the payload.

Key Type Default Meaning
log_level event_only | payload_trimmed | full event_only As above. Any other value is a parse error.
log_payload_max_bytes integer 4096 Cap used by payload_trimmed. Ignored at every other level.
---
flowmarkdown_version: "0.1"
flow: forward-order
tenant: acme
effects: [http_egress]
log_level: payload_trimmed
log_payload_max_bytes: 8192
---

What gets recorded is the payload at the moment the verdict is written: the flow’s output on completed and queue_processed, and the value the execution failed on for fault, queue_failed and dlq_moved. Same masking, same trimming, same level. The inbound body of a successful call is never stored as such: received is emitted before the flow’s log level is known.

An error message names the shape, never the value

A runtime error tells you where it happened and what shape it found. It never prints the value it found:

step 'read': field access '.iban' on a String
step 'total': arithmetic on a non-number, got Object

That is deliberate, and it is why the failure events carry a payload at all. An error message goes to four places that are not masked — the process log, the dead-letter row, this error field, and the audit chain — so a message that printed the value would put message content in all four, at any log level, including the default. The value goes to payload instead, which is masked, is trimmed, and is governed by log_level.

So diagnosing a type error reads two fields, not one:

  • error — the step, the path, the expected type and the type found. Always there.
  • payload — the value, masked. Only at payload_trimmed or full.

At the default event_only the value is nowhere. If an incident needs it, raise log_level on that flow and reproduce; do not look for it in the message.

One exception, and it is the operator’s: the dead-letter row keeps the whole message body, in the clear, because a dead-letter queue that does not keep the message cannot replay it. Nothing expires it either — log_retention_days governs this log, not the queues — so retention there is a procedure you run, not a setting you turn on.

A flow whose output is XML records no payload, at any log level. That covers both a SOAP response and a plain XML response: a document is not kept in the log store. The event itself is still written, with its duration_ms and correlation id — you lose the body, not the trace.

If you need the contents of an XML answer in the log, log the parts and not the document: a step that reads the fields you care about into ctx.vars, or a flow that answers with an object and leaves the XML for an egress step. Note the trade-off before you do: masking works on object keys, so fields you extract are masked by name, while a document would not have been.

Payload masking

A fixed set of field names is always replaced with "***". This cannot be turned off, reduced, or overridden, at any log level.

user_name username user password newpassword
oldpassword passwd pwd code token
access_token refresh_token authorization secret apikey
x-api-key value credential id newcredential
oldcredential identifier key authorizeduser phonenumber

Matching is case-insensitive on the field name, and it applies at every depth. Two consequences that surprise people:

  • The whole value goes, not just a scalar. If a key matches, its value is replaced even when that value is an object. A payload of {"user":{"name":"Alice","city":"Oradea"}} is recorded as {"user":"***"} — the subtree is gone, not masked field by field.
  • id, key, value and code are on the list. These are ordinary field names in ordinary payloads. If your messages use them, expect the log to show "***", and do not conclude the field was empty. Where you need a business identifier to stay readable, name it something else — orderId is not on the list and is recorded in full.

A string with no field name

Masking decides by field name, and a string that no field name leads to has none: the whole payload when it is a string, or an element of an array that is the whole payload. A CSV carries its column names (email, iban, …) inside such a string. So that string is never recorded as its content. At payload_trimmed and full, and in the support trace too, it is recorded as:

{"_text":{"bytes":45,"blake3":"9dc753d7d62b83a964419dd8f113b7a17b63d4831bf53b51f242a6dd930f2fda"}}
  • This covers a text/* body queued on /enqueue, a .csv file the VFS connector picks up, a JSON body that is a bare string ("ana.pop@example.ro"), and a step whose output is a string.
  • bytes is the length of the string in UTF-8, and blake3 the BLAKE3 digest of those bytes, 64 lowercase hexadecimal characters. For a CSV file the bytes are the file’s, so the digest can be compared with b3sum of the file on disk.
  • The text itself is kept where the message is kept: in the queue, and in the dead-letter queue if the delivery fails.
  • A string a flow places under a field name, {"raw": $} for instance, is recorded as any field: in clear, unless the name is masked. Name it in log_extra_mask_fields (below) to mask it.
  • Numbers, true, false and null are recorded as they are.
  • The digest of a short string ("yes", a one-row CSV of a known person) can be confirmed by anyone who can read the log and guesses the content.

Adding fields

log_extra_mask_fields is additive. It cannot remove anything from the list above.

---
flowmarkdown_version: "0.1"
flow: onboard-customer
tenant: acme
log_level: payload_trimmed
log_extra_mask_fields: [taxNumber, accountNumber, dateOfBirth]
---

A comma-separated list, with or without brackets. Matching is case-insensitive, like the global list.

How truncation interacts with it

The order is fixed and it is the safe one: mask first, then measure.

At payload_trimmed, the payload is masked, the masked form is serialised, and only then is its length compared to log_payload_max_bytes. If it fits, it is stored as JSON. If it does not, the whole payload becomes a single string:

"payload": "[TRIMMED:4096B] {\"orderId\":\"A-1\",\"lines\":[{\"sku\":\"AB-1\","

The cut is at a character boundary, so the string is always valid UTF-8, but it is not valid JSON — it is a prefix. You cannot query into a trimmed payload; raise log_payload_max_bytes for the flows where you need to.

Because masking runs before the measurement, truncation can never expose a masked value, and a payload too large to store cannot leak one either.

At full there is no measurement and no cap. Masking still applies.

Business classification

Two free-text keys, copied onto every event that knows the flow’s configuration, into the project and scenario columns of flow_log. A query you run against the log database can use them to select the flows of one system without matching on flow names; the UI and the CLI do not filter on them.

Key Type Default Meaning
log_project string none The application or system this flow belongs to.
log_scenario string none The use case within it.
---
flowmarkdown_version: "0.1"
flow: forward-order
tenant: acme
effects: [http_egress]
log_project: orders
log_scenario: intake
---

Nothing validates or interprets them. Pick a vocabulary and keep it consistent — they are only as useful as they are uniform.

The audit trail

A second, separate record in nexus-audit.db. Append-only: no update and no delete is ever issued against it. One chain per tenant, with its own sequence starting at 0.

What an entry contains

{
"id": 42,
"tenant_id": "acme",
"sequence": 7,
"event_type": "key.created",
"correlation_id": "",
"payload": {
"action": "key.created",
"key_id": 5,
"key_label": "billing importer",
"tenant": "acme",
"scopes": ["run"],
"expires_at": null,
"allowed_flows": null,
"subject": { "kind": "ui_user", "id": 7, "label": "ana" }
},
"format_version": 2,
"prev_hash": "9c2e…",
"hash": "4a9c…",
"created_at": "2026-08-09T14:02:14.318Z"
}
Field Meaning
id Autoincrement row id. Assigned by the database, outside the hash.
tenant_id Which chain this entry belongs to.
sequence Position in that chain, starting at 0.
event_type One of the kinds listed below, e.g. key.created.
correlation_id The triggering request’s correlation id, empty string if there wasn’t one.
payload Event-specific facts — see the tables below. Never the credential, the message content, or a password.
prev_hash The hash of the entry immediately before this one in the same tenant’s chain. Empty string for the first entry.
format_version 1 for an entry written before the time was covered by the hash, 2 for every entry written since. See “The hash chain” below.
hash The hash of this entry’s own content — see “The hash chain” below.
created_at UTC time with milliseconds, YYYY-MM-DDTHH:MM:SS.mmmZ, read from the clock of the process that appends the entry, under the write lock. Inside the hash of a version 2 entry. An entry of version 1 holds a time to the second that the hash does not cover.

There is no subject column. Who acted is folded into payload, under the key "subject", with three fields:

subject field Meaning
kind One of ui_user, local_operator, api_key — see below.
id The row id of the subject in its own table. Never cryptographic material — an i64 cannot hold a raw key or a hash.
label A human-readable name: the account’s username, the key’s label, or a fixed placeholder for the CLI.

The three kind values, and what identifies the actor for each:

kind id is label is When it appears
ui_user ui_users.id, the real row of the account that was logged in its username The act came from the management UI.
api_key api_keys.id, the key that was presented the key’s own label The act is access.denied for a caller who presented a valid, identifiable key but was refused on rights.
local_operator always 0 — not a lookup, there is no table of CLI users always the literal string "nexus CLI" The act came from running the nexus binary. A declared placeholder: the platform never authenticates whoever is at the terminal, so two different administrators running the CLI on the same machine produce identical, indistinguishable entries.

An entry with no subject at all — a queue.message.* or grpc.call row — has no "subject" key in its payload, because nobody decided it; it happened as a consequence of traffic, not of an operator’s choice.

What is recorded

Two families, split by whether the entry can be attributed to someone.

Policy acts — somebody decided something — carry a subject: who acted, as a management-UI user, the local operator placeholder for a CLI command, or the API key at the gate. Most land on the chain of the tenant they concern:

Event type Written when
tenant.registered A tenant was registered. Payload: display name and the authentication methods granted. Normally the first row of that tenant’s chain
tenant.disabled A tenant was taken out of service
tenant.enabled A disabled tenant was put back. Note this restores nothing else — keys that were never revoked start authenticating again at that moment
key.created An API key was issued. Payload: row id, label, scopes, expiry, flow allowlist — the rights granted, never the key or its hash
key.revoked An API key was revoked. Payload: row id and label; the rights are not repeated, they are already in this chain
dlq.replayed An operator put a dead-lettered message back on the queue. Payload: message id, attempts, timestamps, size — never the content. ack_failed: true when the message went back on the queue but its dead-letter entry could not be marked as handled: it is then in both queues
dlq.discarded An operator gave up on a dead-lettered message. Same payload, different type: a message back on the wire and a message abandoned must be distinguishable without interpretation
state.lease_released An operator released a delivery lease by hand, knowingly authorising a second concurrent execution. Payload: flow, key fingerprint, previous lease owner and expiry, attempt, correlation id
access.denied A caller was refused at the gate and could be identified. Payload: route, flow, correlation id, operator-facing reason. On the credential’s own tenant chain, never the tenant in the request path
trace.enabled A sysadmin turned on the payload trace for one (tenant, flow) pair. Payload: flow
trace.disabled The counterpart — the trace was turned back off. Payload: flow
flow.deployed A flow version was published and latest moved to it. Payload: tenant, flow, version, hash, previous_version
flow.undeployed A flow was undeployed. Payload: tenant, flow, version, hash, routes_removed, trace_was_enabled
proto.deployed A descriptor version was published. Payload: tenant, name, version, hash, latest_moved, previous_latest
act.intended The first entry of an administrative act: what is about to be done and to what. Payload: action and the facts of the act
act.failed The act was intended and did not happen. Payload: intent (sequence of the intent) and reason, one of precondition, no_change, error
audit.gap Entries could not be recorded. Payload: action, dropped, first_event_type, first_time, last_time, event_types; on a break-glass also unrecorded_act and by_operator_choice (unrecorded_acts lists all of them when there are several). A gap written for a break-glass act found in the registry carries unrecorded_act, by_operator_choice, found_by (registry), registry_time (the registry’s own time of the act, as stored) and, for a key, key_id; it has no subject and no dropped

Still policy acts, still carrying a subject, but eleven of them have no tenant to land on: management-UI accounts, installation settings, and profile actions belong to the installation, not to any one tenant. The chain is per tenant, so these go on a single reserved tenant, nexus, that exists in the registry for exactly this purpose and cannot be called like a real one:

Event type Written when
ui_user.created A management-UI account was created. Payload: row id, username, role
ui_user.role_changed An account’s role changed. Payload: row id, username, new role, and the previous role — the only place the old value survives
ui_user.disabled An account was disabled. Payload: row id, username, role at the time
ui_user.password_changed Someone changed their own password. Payload: row id, username, sessions_ended (how many other sessions of the account the change ended; the session it was made from is replaced and not counted) — never the password, its hash or any session token
ui_session.revoked One UI session was revoked. Payload: session id
ui_session.revoked_all All other sessions of the account were revoked at once. Payload: how many
system_config.changed An installation-wide setting was changed. Payload: the key, the previous value (null if it was never set), the new value. One row per key changed
maintenance.compaction_run An operator ran queue compaction by hand from the Settings page. Payload: queues compacted; queues skipped, by reason (empty, worker_in_process, worker_elsewhere, not_drained, dead_letters); failures, by kind (unlisted, inspect, inconsistent, delete). Which queues and why are in the system log
maintenance.retention_run An operator ran the log-retention sweep by hand. Payload: rows deleted, files deleted, the retention-days threshold used
state.key_replaced A start with nexus serve --accept-new-state-key replaced the state key the stored flow state was written under. Payload: previous_check and new_check (check values that name the two keys without revealing them), inbox_rows and correlation_rows (the deduplication rows and correlations that stopped matching). Never the key

Processing events are the second family: no subject, because nobody decided them — they are a consequence of traffic, not of an operator’s choice.

Event type Written when
queue.message.processed A queued message ran successfully. Payload: flow, message id, attempt, duration
queue.message.failed A delivery attempt failed and a retry is scheduled. Payload: flow, message id, attempt, error, delay before the retry
queue.message.dlq A message was dead-lettered. Payload: flow, message id, attempt, error
queue.segment.retired A queue’s worker removed a sealed segment whose messages were all settled. Payload: flow, segment_start (the byte offset the file started at), bytes (its length), oldest_start (the start of the oldest segment left). No message content and no correlation id
grpc.call A gRPC call finished, successfully or not. Payload: step, descriptor name and version, descriptor hash, service, method, outcome, number of messages received

That is the whole list, and note what is not in it. A refusal the platform cannot attribute — no credential, a malformed one, an unknown tenant asked for anonymously — writes no audit row at all, only a structured-log event: without a subject it is a counter, not an attribution, and it is also what stops an anonymous caller from starting a chain on a tenant id it invented. A 429 is the same: a load measure, not a verdict on rights.

Deployments are not here either, and not in the structured log — a deployed version is recorded by the registry itself, with its version, hash and timestamp, which is what the Registry page shows. Successful /run calls are in the structured log, not in the chain.

An administrative act is recorded before it is performed. Issuing or revoking a key, registering, disabling or enabling a tenant, replaying or discarding a dead letter, releasing a lease and every change in Settings first append act.intended, with the facts of the act and never a credential. If the chain cannot take that entry, the act is refused: the command exits non-zero, the management UI sends the operator back to the page the form came from with a fixed message in a banner (nothing was changed), and nothing changes. When the act finishes, its outcome is appended under its own event type with an intent field holding the sequence of the intent; an act that was intended and then failed is closed with act.failed and a reason class (precondition, no_change or error). nexus audit --verify prints an intent with no outcome as a warning: the chain does not say whether that act happened. A process that dies between the act and its outcome leaves exactly that. If the outcome append itself keeps failing, the only traces are the error on stderr and in the structured log, and that warning.

nexus keys revoke and nexus tenant disable take --accept-unrecorded, for an incident where the disk is full: the act is performed without a recorded intent. If the same command can still write its outcome, that entry is preceded by an audit.gap that names the act and says it was the operator’s choice. If it cannot, the command exits and the chain holds nothing yet; the registry still holds the revoked key or the disabled tenant. Whatever writes the chain next compares the two and appends an audit.gap for each act the chain does not hold, with found_by: registry, registry_time and, for a key, key_id. That happens at three moments, whichever comes first: when nexus serve starts, before it accepts traffic; at the start of the next command that performs an act (a change through nexus keys, nexus tenant, nexus dlq or nexus state release; a listing does not), before its own intent; and in a running server, at its first successful audit append after appends had failed. Each act is written once. A pass that cannot write logs an error per tenant and changes nothing else: the server still starts and the command still runs. No other command has the flag and the UI has no equivalent.

by_operator_choice on a gap found in the registry is an inference: the only way an act reaches the registry without its intent is --accept-unrecorded. Until a pass has run, nexus audit --verify names the act as a warning (the registry records key.revoked of key id N at … and the chain holds no entry for it), reading registry.db beside the audit database read-only; with no registry there it prints acts not reconciled: no registry beside the chain. The warning does not change the exit status, and verify --file does not run the check, since an export has no registry.

What the marking does not cover:

  • An act reversed before any pass runs is not found: a tenant disabled unrecorded and re-enabled with a recorded tenant.enabled before a pass shows only the enable.
  • A running server that saw no failed append does not run the recovery pass; nexus audit --verify still names the act.
  • A copy of the chain without its registry, or an export taken before a pass, does not show the act.
  • The gap’s place in the chain records when the act was found, not when it was performed; registry_time says when.

Events that are not acts are best-effort. A message moving through a queue, a gRPC call and an attributable refusal at the gate are recorded, but when the chain cannot take one the work goes on: the failure is logged as an error and counted per tenant, and the next entry appended for that tenant is preceded by an audit.gap with the number dropped, the first event type, the first and last time, and up to 20 event types. The count lives in the process: a process that stops before the chain recovers takes it along. nexus serve does not start when it cannot open the audit chain.

The hash chain

Each entry stores the hash of the one before it, and its own hash covers that link. Two formats exist, told apart by the entry’s format_version.

Version 2 (every entry written by this release) is the lowercase hexadecimal SHA-256 of the text nexus-audit-entry-v2, a NUL byte, and then one JSON object in the canonical form of RFC 8785 (the JSON Canonicalization Scheme). The object has eight members: correlation_id, created_at, event_type, format_version (always 2), payload, prev_hash, sequence and tenant_id. The stored payload text is that same canonical text, so a reader can recompute the hash from the columns of the row with nothing but a SHA-256 function and a JSON canonicaliser. A payload is always a JSON object; its numbers are integers whose absolute value is at most 2^53 - 1, so a floating-point number is refused when an entry is appended, and nothing is written.

Version 1 (entries written before) is blake3 over these fields, NUL-separated, in this order:

tenant_id
sequence (8 bytes, big-endian)
event_type
correlation_id
payload (the stored JSON text)
prev_hash ("" for the first entry in a tenant's chain)

The time of a version 1 entry is outside its hash, so it cannot be vouched for. The row id is outside the hash of both versions: the database assigns it and it says nothing about the entry’s content.

A chain may hold version 1 entries followed by version 2 entries, never the other way round, and the first version 2 entry carries the hash of the last version 1 entry as its prev_hash. The step is made once, on the first start of a release that has it: the column format_version is added, every existing entry reads as version 1, and no existing row changes. There is no downgrade: an older binary does not know the column or the version 2 hash, and reports every new entry as a hash mismatch.

The clock. The time is the clock of the host that appends the entry, and the platform trusts it: keep the host synchronised (NTP or the platform’s equivalent). When an entry is stamped earlier than the one before it, the append is not refused, because a clock fault must not turn into lost evidence; nexus audit --verify prints a warning with how far back it went, and the verdict stays OK. An entry dated in the future is not flagged: only a version 2 time that is earlier than the one before it is a warning. Two engines appending to one chain have two clocks, so the order of times can differ from the order of sequence numbers by the skew between them.

So changing any recorded field of a version 2 entry, reordering entries, or deleting one from the middle breaks the chain at that point and at every point after it. Appending a forged entry to the end requires knowing the current tail hash, which is in the same database — the chain proves tampering with history, not that a well-resourced attacker with write access never appended anything.

Querying and verifying

Terminal window
$ nexus audit --tenant acme --limit 3
seq fmt event_type created_at correlation_id
----------------------------------------------------------------------------------------------
41 2 queue.message.processed 2026-08-09T14:05:02.311Z 7c1e9a20-...
payload: {"attempt":1,"duration_ms":74,"flow":"forward-order","msg_id":12}
40 2 queue.message.dlq 2026-08-09T14:02:14.870Z 3f8b1c42-...
payload: {"attempt":3,"error":"step 'forward': effect handler failed: HTTP 503 Service Unavailable","flow":"forward-order","msg_id":7}
39 1 queue.message.failed 2026-08-09T14:02:12Z 3f8b1c42-...
payload: {"attempt":2,"error":"step 'forward': effect handler failed: HTTP 503 Service Unavailable","flow":"forward-order","msg_id":7,"retry_in_ms":2000}

The fmt column is the entry’s format_version.

Flag Default Meaning
--tenant <t> required Chains are per tenant; there is no cross-tenant view.
--limit <n>, -l 20 Most recent entries first.
--verify off Also walk the whole chain and check it.
--db <path> ~/.nexus/nexus-audit.db The audit database.

--verify checks these over every entry in the tenant’s chain: that sequence numbers are contiguous from 0, that each prev_hash equals the previous entry’s hash, that each hash recomputes from the entry’s own stored columns (the stored text, not a re-serialised copy), that a version 2 entry’s stored payload is in canonical form, that created_at is a valid time, and that format_version never goes back from 2 to 1. --verify on a file that has no audit table fails, and on a tenant with no entries it prints that there are none and exits zero. Reading and verifying never change the database, and never create one: a path that does not exist is an error, not a new empty file. They also work on a file that is read-only or that was written by an earlier release.

Terminal window
$ nexus audit --tenant acme --verify
...
chain integrity: OK (41 entries checked)
format 1 (time not covered by the hash): 12, format 2: 29
tail NOT anchored: truncation cannot be detected

The last line is always there. A chain whose links all hold can still have lost its tail, and nothing inside the same database can tell: the verdict says so instead of printing a bare OK. See Anchoring the tail.

Warnings (a time that went backwards) are printed above the verdict, the first 20 and then a count, and do not change it. The command collects every problem rather than stopping at the first, and exits non-zero when there is one:

Terminal window
$ nexus audit --tenant acme --verify
...
chain integrity error: hash mismatch at sequence 17: stored=4a9c…, computed=81ef…
error: audit chain integrity check failed (1 error(s))

--limit does not affect verification; the whole chain is read either way. On a long chain this is not a cheap command.

Exporting a chain

Terminal window
$ nexus audit export --tenant acme --out acme-audit.jsonl
exported 41 entries (sequence 0 to 40) for tenant 'acme'
Flag Default Meaning
--tenant <t> required The chain to export.
--from-seq <n> 0 First sequence to write.
--to-seq <n> the last entry Last sequence to write.
--out <file> standard output Destination; - is standard output. A failed export leaves no file behind.
--db <path> ~/.nexus/nexus-audit.db The audit database.

The file is JSON Lines, one RFC 8259 object per line:

  1. A header: kind (nexus-audit-export), export_version (1), tenant, from_sequence, from_prev_hash (the hash the first entry chains to, "" for sequence 0), exported_at and producer.
  2. One line per entry, in sequence order, with the stored columns: tenant_id, sequence, format_version, event_type, correlation_id, created_at, payload, prev_hash, hash. payload is the stored text as a JSON string, so whoever verifies hashes exactly what was hashed.
  3. A trailer: the checkpoint of the exported tail, with "source":"export". A file without it was cut short and does not verify.

An export is a read: it writes nothing to the chain, and it takes the rows in pages, so a long chain does not hold the writer’s lock for its whole length.

Verifying outside the installation. The format needs nothing of the platform. To recompute a version 2 hash, take the eight members listed under The hash chain, build the object, write it in the canonical form of RFC 8785, put nexus-audit-entry-v2 and a NUL byte in front, and hash it with SHA-256; version 1 is the blake3 layout above. The checkpoint digest is the SHA-256 of the canonical form of its body. nexus audit verify --file does it for you:

Terminal window
$ nexus audit verify --file acme-audit.jsonl
chain integrity: OK (41 entries checked)
format 1 (time not covered by the hash): 12, format 2: 29
tail NOT anchored: truncation cannot be detected

It needs no database and no platform state, and exits non-zero when the file does not verify or cannot be read. nexus audit verify --tenant <t> [--db <path>] runs the same verifier over the database. A range export (--from-seq) cannot be checked alone, because nothing says what comes before it: give the previous export with --continues-from <file>, or the entry before it with --prev-tail <sequence>:<hash>.

Anchoring the tail

Deleting the last entries of a chain, or rewriting it from some point and recomputing every later hash, leaves a chain that is consistent with itself. To catch it, the platform records where each chain stood somewhere else: a checkpoint.

{"kind":"nexus-audit-checkpoint","version":1,"tenant":"acme","size":41,"tail_hash":"81ef…","tail_format_version":2,"time":"2026-08-09T14:05:02.311Z","digest":"c3d0…"}

size is the last sequence plus one. digest is the lowercase hexadecimal SHA-256 of the canonical form of the other members, so a signature or a timestamp that you make over it adds to it and does not replace anything.

A checkpoint leaves as an audit.checkpoint event of the structured log (the checkpoint is the event’s payload), so it lands in the daily files that ship off the host: after every administrative act, after every hundredth entry of a tenant, when nexus serve starts, and when you run nexus audit checkpoint --tenant <t> [--db <path>], which also prints it. A tenant with no entries has nothing to anchor.

Terminal window
$ nexus audit verify --tenant acme --anchors nexus-log-2026-08-09.host-1234.jsonl
chain integrity: OK (41 entries checked)
format 1 (time not covered by the hash): 12, format 2: 29
tail anchored up to sequence 40 (2026-08-09T14:05:02.311Z)

--anchors <file> takes an L1 file, or a concatenation of them, or export files (their trailers are checkpoints too). The chain is held to every checkpoint of its tenant:

Verdict Means
tail truncated: … and the number of entries missing The chain has fewer entries than a checkpoint says it had.
anchor mismatch The entry a checkpoint names has another hash, or another format, than the one the chain holds now: the history was rewritten.
tail anchored up to sequence N The newest checkpoint matches. Entries after N are not covered.
tail NOT anchored No checkpoint of this tenant was given or none applies. The chain may have lost its tail.

--require-anchor turns the last row into a failure, which is what a scheduled check wants. The anchors file is read strictly: a line that is not JSON, or a checkpoint whose digest does not match its body, stops the check with the line number, because a source of evidence that is skipped silently is not one.

Ranges and anchors. verify --file on a range export holds the range to every anchor of its tenant. An anchor whose size is beyond the last entry of the range is reported as tail truncated, because the file really does end before the place the checkpoint names. Give a range only the anchors up to its end, or verify the whole chain. An anchor before the range’s first entry says nothing about it and is skipped.

Intents without an outcome. An act.intended entry that no later entry resolves is reported as unresolved. In an export the check sees only the exported range: an outcome that sits beyond the range is not seen, so a range that ends right after an intent reports it as unresolved even when the whole chain closes it.

Where the anchors must come from. The anchors file has to come from outside the audited installation. The nexus-log-*.jsonl files sit beside the audit database, so someone with the rights of the host can rewrite both, and a chain checked against files that same person wrote proves nothing. Anchors also depend on the audit.checkpoint events reaching the log: log retention can delete the files that hold them, and when the log’s queue is full under load an event can be dropped. A missing checkpoint weakens the check and never makes it fail, so keep a regular export, whose trailer is a checkpoint too, off the host.

Time-stamping. The digest of a checkpoint is the value to submit to an external time-stamping authority (RFC 3161): the token proves the chain stood so at that time, and it adds to the checkpoint without changing it. The platform does not sign or time-stamp a checkpoint.

What this does not cover. A checkpoint is only as good as the place it is kept: it is written to the log files of the same host, so someone who can rewrite the chain and those files together is not caught. Ship the log files, or keep the trailer of a regular export, somewhere that host cannot write. Entries made after the last checkpoint are not anchored: administrative acts anchor at once, other events only at every hundredth entry of a tenant, so up to 99 of them can be removed from the tail without a trace. The platform does not sign or time-stamp a checkpoint.

In the management UI, a sysadmin sees Download export next to Verify on the audit page; it downloads the same file for the tenant shown. The download is a read: it adds nothing to the chain, and the platform’s operational log names who took it.

The management UI

The same process serves a web UI at /ui. With the default port:

http://localhost:9090/ui

It reads the same databases the server writes. Its accounts and sessions are entirely separate from API keys — a UI login grants nothing on the flow endpoints, and an API key grants nothing in the UI. Creating the first account is described in Server and configuration.

Pages

Page Shows
Dashboard Volume and error indicators, recent deployments, health.
Registry Every deployed flow, its versions, hashes and deployment times, and the executions of each running now.
Logs The structured log, filterable by tenant, flow, level and free text, paged.
Stats Charts over the log: volume, errors, latency, busiest flows and tenants.
Queue Per-queue counts of pending, retrying and dead-lettered messages. The replay and discard buttons are sysadmin only.
State Deduplication rows (flow_inbox) and open correlations (flow_correlations), filterable by tenant, flow and status. Read-only.
Profile Your own display name, password, theme, language, and active sessions.
Tenants Create tenants, and disable or re-enable one: a disabled tenant’s API keys stop authenticating at once.
API Keys Create, list and revoke keys, including the per-key flow allowlist.
Audit The audit chain, with a verify button.
System Logs The structured log of every flow, as on Logs, for sysadmin only. The operational log the process writes about itself is not in the UI: it goes to stderr, filtered by RUST_LOG.
Users Create users, change roles, disable accounts.
Settings Log retention and queue compaction, and buttons to run either now.
Certificates Expiry of the certificates the deployed flows use, reconciled against the registry.

The Registry page’s Running column shows, for each flow, the executions running now on the engine that serves the page. A flow that declares max_concurrent_executions: shows current / limit, as 1 / 2; a flow without the key shows the count and “no flow limit”. The count is that engine’s only: another engine of the same installation keeps its own.

The data behind the Stats page, GET /ui/api/stats/data, also carries validity: for each tenant, how many certificates are valid, pending, expiring, expired or unreadable, and how many non-revoked API keys are valid, expiring, expired or unreadable, against the warning window of the expiry sweep. Counts only, no names: the names are on the Certificates page and in nexus keys list --expiring-in. The counts are the state now, whatever from, to and channel say. The Dashboard’s API keys tile counts “expiring soon” over the same window.

The Dashboard and Stats pages render fully in English or in Romanian, with no leftovers from the other language. The other pages are not fully translated.

Roles

Three, set per user, changed from the Users page.

Role Navigation offers
support Registry; Traffic (Queue, Logs, State); Profile.
developer The same, plus Overview (Dashboard, Stats).
sysadmin Everything, including Administration: Tenants, API Keys, Certificates, Audit, System Logs, Users and Settings.

The pages sit in a bar across the top, in the groups above; the group you are in shows its pages as tabs under the bar. Ctrl K (⌘ K on a Mac) opens Go to…, a search over the same pages. After signing in, support starts on Registry, the first page of its menu; the other two roles start on Dashboard.

Read that column precisely: it is what the navigation shows, which is not the same as what the server enforces. Two different lines, and you need both.

The model below is a decision, not an accident of how the pages were written: one enforced boundary, plus one role that sees something the others do not.

What the server enforces is sysadmin, and only sysadmin. It guards the seven pages of the Administration group — Tenants, API Keys, Certificates, Audit, System Logs, Users, Settings — and, on pages any role can open, three actions:

Action Page Why it is not a support action
Replay a dead-lettered message Queue It puts the message back on the wire
Discard a dead-lettered message Queue It destroys the message
Turn payload tracing on or off for a flow Registry It changes what gets recorded

Everything else needs a valid session and nothing more. A support user who types the Dashboard or Stats URL gets the page — those two handlers ask for a session, not a role. developer is a label on the person, useful for knowing who is who; it is not a privilege, and nothing checks it.

support is not a lesser role — it is the troubleshooting one, and it is the only role that reads the payload trace. A sysadmin who asks for step_traced or egress_traced by name gets zero rows: someone who can change every setting in the installation still cannot read a flow’s messages just by asking for them. That boundary is enforced where the rows are selected, not hidden in the page. See Payload tracing — and note the division of labour there: only sysadmin decides that a trace gets collected, only support reads it.

One consequence of the shape above is worth stating plainly, because a table of roles invites the opposite conclusion: every role reads the ordinary log, including recorded payloads. The Logs page shows them for any flow whose log_level records them, and a support account is exactly the kind of account that gets handed out widely. log_level is therefore the control that decides who can see message contents — not the role. Choose it with that in mind, and see Log levels.