Skip to content
Ironflow is pre-1.0: APIs can change between releases. Pin a version. What is ready →

Audit Log

The Audit Log shows recorded execution, security, and administrative events in an environment. Function recording controls workflow run, step, and saga capture. Security and resource events are captured independently of that flag, subject to their capture settings. For example, KV audit emission requires IRONFLOW_AUDIT_KV_EMIT=1. Platform-scoped records are available in Platform Audit.

The dashboard enables Hide successful authorization checks by default to keep routine reads from crowding out activity. Uncheck it, or use Clear filters, to see successful checks alongside other events. Selecting authz.decision.allowed explicitly also turns off the hide control.

Category presets narrow the displayed events:

  • Workflow execution: run, step, saga, and debounce events, including operator actions on runs and steps.
  • Security: authorization and platform event families, within the endpoint’s existing scope.
  • Administration and infrastructure: other event families, including function, deployment, resource, and capacity events.

The category and hide controls filter loaded rows. They do not change capture or remove stored records. Function, run, and event-type filters are sent to the audit query. Active filters remain visible above the table. Counts distinguish shown rows, loaded rows, and the total matching the server query. Use Load More to inspect older pages, including when every loaded row is hidden by display filters. The page does not search all older pages automatically.

Loading, request failures, an empty feed, and no matching results have separate messages. Retry repeats a failed request; a failed refresh keeps previously loaded rows visible. Authorization decision rows show action, actor, API key, resource, and decision. Expand any row to inspect its full payload and metadata.

If the environment has no recorded events, check capture configuration and retention as well as function recording. Enabling function recording does not control security or resource capture.

ConceptDescription
Audit EventAn immutable record of execution, security, or administrative activity.
RecordingPer-function toggle for workflow run, step, and saga audit capture.
Audit TrailThe ordered sequence of audit events for a run, queryable by type and time range.

Every audit event has a event_type that identifies what happened. The types are defined as AuditEventType constants in internal/store/models.go, which is the single source of truth. The dashboard catalog is generated from those declarations and their scope/availability metadata. The database does not constrain the column (see ADR-0052).

The tenant event selector offers available tenant-scoped types, including deployment.registered and deployment.conflict_rejected. Platform-only types, including cluster.encryption.verified, belong in Platform Audit and are omitted from tenant choices. cluster.token.rotated is tenant-scoped despite its prefix. The backend enforces query scope even if a caller supplies a platform event type.

platform.key.created and platform.key.revoked remain reserved declarations: there are no current producers for these event names. They are omitted from the selector, and an older link selecting one is labeled as reserved. This does not remove access to stored records or introduce new capture behavior.

For contributors: add an explicit AuditEventType string constant with a trailing // audit: tenant available annotation, using platform or reserved where appropriate. Check the producer’s storage scope rather than inferring it from the name. Run make audit-catalog and commit the generated file. make audit-catalog-check verifies parity without rewriting the file and tests the generator. This gate runs in make ci and before make web. Availability describes producer support; actual capture still depends on the applicable recording and operator settings.

Event TypeWhenKey Payload FieldsGlobally redactable fields
run.createdA new run is createdrunId, functionId, input, eventIdinput
run.status_changedRun status transitionsrunId, oldStatus, newStatus, reasonreason
step.startedA step begins executionstepId, stepName, stepTypestepName
step.completedA step finishes successfullystepId, output, durationMsoutput
step.failedA step failsstepId, error, attempterror
step.patchedA step output is hot-patchedstepId, originalOutput, patchedOutput, patchedByoriginalOutput, patchedOutput
saga.compensation.startedA compensation step beginsstepId, compensationTargetStepNone
saga.compensation.completedA compensation step succeedsstepId, resultresult
saga.compensation.failedA compensation step failsstepId, error, attempterror
step.injectedStep output injected during scoped injectionstepId, previousOutput, newOutput, reasonpreviousOutput, newOutput, reason
run.pausedReserved — declared but not currently emitted; a pause records run.status_changed—None
run.resumedRun resumed after pauserunId, actorId, fromStep, newStatusNone
run.cancelledA run was cancelled by an operatorrunId, actorId, reason, causereason
run.deletedA terminal run was deletedresource_id, name, org_id, env_id, actor_id, change_typeNone
entity_stream.deletedAn entity stream was deletedresource_id, name, org_id, env_id, actor_id, change_typeNone
event.redactedAn event’s data was replaced with a placeholderresource_id, name, org_id, env_id, actor_id, change_typeNone
step.redactedA step’s output was replaced with a placeholderresource_id, name, org_id, env_id, actor_id, change_typeNone
run.redactedA run’s payloads were replaced with a placeholderresource_id, name, org_id, env_id, actor_id, change_typeNone
debounce.cancelledA pending debounce entry was cancelledenvironment_id, function_id, debounce_keydebounceKey
environment.createdEnvironment createdenv_id, env_name, project_id, org_id, actor_id, change_typeNone
environment.deletedEnvironment deletedenv_id, env_name, project_id, org_id, actor_id, change_typeNone
environment.updatedEnvironment renamed or recoloredsame as aboveNone

Set IRONFLOW_AUDIT_PAYLOAD_REDACT_FIELDS to a comma-separated list of exact <event_type>.<field> selectors. The setting applies to the whole server process and is read once at startup. Empty or unset configuration keeps full payload capture. For example:

Terminal window
IRONFLOW_AUDIT_PAYLOAD_REDACT_FIELDS='run.created.input,step.completed.output' ironflow serve

The table above is the complete selector catalog. Combining each event type with one field in its last column gives the accepted selectors. Nested paths, wildcards, duplicates, unknown fields, and selectors for protected event families are rejected before the server accepts traffic. Authorization, platform, resource, control-plane, capacity, and scanner families keep their existing capture rules. The cataloged run.cancelled.reason remains selectable even though cancellation is an operator action.

Ironflow copies and removes selected top-level fields before it serializes or writes the audit row. It records the canonical omitted field names as a sorted, comma-separated audit_redacted_fields metadata value. The marker contains no omitted values. debounce.cancelled.debounceKey is the public selector. It removes the existing debounce_key field from the stored JSON and records debounceKey in the marker.

The policy affects rows written after the configured process starts. It does not rewrite existing audit rows. Audit queries and exports omit the selected fields on affected rows and expose the metadata marker so consumers can tell redaction from a field the producer never supplied.

Redaction changes only the audit copy. Durable run and step inputs, outputs, and errors remain available through execution-state APIs and continue to drive retry, resume, and memoization. Time travel still requires recordingProfile=all, but that profile cannot restore a globally redacted value. Reconstructed snapshots omit the selected input, output, error, or patch value, and GetStepOutputAt returns no value for a redacted output.

If runtime sanitization fails, Ironflow skips that audit row, records a delivery failure, and logs the event type. The workflow and its durable state continue. Ironflow never falls back to the unsanitized payload.

Exception events only — normal queue/reserve/release transitions are metrics and logs, never audit rows.

Event TypeWhenKey Payload Fields
capacity.lease.expiredA worker lease expired and was fenced/reclaimedenvironment, executionSeq, laneDigest, leaseTokenHash, reason
capacity.segment.recoveredAn expired segment was re-enqueued under a fresh executionSeqenvironment, executionSeq, laneDigest, leaseTokenHash, reason
capacity.stale_mutation.rejectedA stale worker message was fenced offenvironment, executionSeq, laneDigest, leaseTokenHash, reason
capacity.refresh.rejectedA stale or unknown lease refresh was rejectedenvironment, executionSeq, laneDigest, leaseTokenHash, reason
capacity.run.readmittedA run stranded waiting for capacity with no queued segment was re-admitted by the orphan sweepenvironment, executionSeq, laneDigest, leaseTokenHash, reason (laneDigest and leaseTokenHash are empty)

laneDigest and leaseTokenHash are opaque digests. Raw keys and lease tokens never reach the payload.

Event TypeWhenKey Payload Fields
authz.decision.allowedAn authorization check passedapi_key_id, action, resource, decision, environment, method, path, policy_id, reason
authz.decision.deniedAn authorization check failedsame as above
authz.decision.would_denyA monitor-mode policy set would have denied the checksame as above, plus policy_set_version, policy_set_mode
authz.key.createdAn API key was createdapi_key_id, key_name, org_id, actor_id
authz.key.revokedAn API key was revokedapi_key_id, key_name, org_id, actor_id, reason
authz.role.changedA role was created, updated, or deletedrole_id, role_name, org_id, change_type, actor_id
authz.policy.changedThe policy set was written or rolled backpolicy_id, policy_name, org_id, change_type, actor_id, old_version, new_version, mode, old_mode (conditional), bypass, bypass_reason (conditional)

A platform API key uses the same authz.key.created, authz.key.revoked, and authz.role.changed types, with platform scope. Read those rows in Platform Audit. Impersonating a tenant does not put them in that tenant’s feed. A change to the tenant’s own key or role stays in the tenant feed.

Scoped platform, not tenant. Read them via /api/v1/platform/audit.

Event TypeWhenKey Payload Fields
platform.user.createdA platform user was createduser_id, email, change_type
platform.user.updatedA platform user was updateduser_id, email, change_type
platform.user.deletedA platform user was deleteduser_id, email, change_type
platform.impersonatedAn impersonation session was startedapi_key_id, platform_user_id, target_org_id, target_env_id, action, allowed
platform.role.changedA delete or update of a built-in role was blockedrole_id, role_name, action, change_type
platform.key.createdReserved — declared but not currently emitted—
platform.key.revokedReserved — declared but not currently emitted—
cluster.encryption.verifiedThe startup encryption pre-flight ranstatus, backend, required, data_path, reason, cluster_id, node_id

cluster.encryption.verified is platform-scoped rather than tenant-scoped because its payload describes the host, not an application: filesystem paths, cluster ID and node ID. It has no actor — the engine emits it at startup.

Event TypeWhenKey Payload Fields
secret.createdA secret was createdname, org_id, env_id, actor_id, change_type
secret.updatedA secret’s value was replacedsame as above
secret.deletedA secret was deletedsame as above
kv.bucket.createdA KV bucket was createdbucket, key, org_id, env_id, actor_id, operation
kv.bucket.deletedA KV bucket was deletedsame as above
kv.key.putA KV key was writtensame as above
kv.key.deletedA KV key was deletedsame as above
webhook.source.createdA webhook source was provisionedsource_id, name, org_id, env_id, actor_id
cluster.token.rotatedA cluster cap-token was rotatedcluster_id, token_kind, actor_id, reason
org.exportA customer-data export completedorg_id, actor_id, started_at, completed_at, include, from (conditional), to (conditional), record_counts, body_sha256, bytes_streamed

Every row below shares one payload shape: resource_type, resource_id, name, org_id, env_id, actor_id, change_type, plus version and fields where they apply.

Event TypeWhenExtra Payload Fields
function.createdA function was registered for the first time—
function.updatedA registered function’s config changed—
function.deletedA function was deleted—
function.pausedA function was paused—
function.resumedA paused function was reactivated—
function.rolled_backA function was rolled back to an earlier versionversion (the target)
projection.registeredA projection was registered or re-registered—
projection.deletedA projection was unregistered—
projection.pausedA projection stopped consuming events—
projection.resumedA paused projection resumed consuming—
projection.rebuild_startedA rebuild job was queued — not that it finished—
projection.rebuild_cancelledA running rebuild job was cancelled—
schema.registeredAn event schema version was registeredversion
schema.deletedAn event schema version was deletedversion
consumer_group.createdA consumer group was created—
consumer_group.updatedA consumer group’s config changedfields (changed field names)
consumer_group.deletedA consumer group was deleted—
deployment.registeredAn executable deployment was registeredversion (the artifact digest)
deployment.conflict_rejectedA registration tried to redefine an existing deployment ID and was refusedversion (the rejected digest)

deployment.registered is written only by the call that actually created the row. A worker fleet re-registers the same build on every boot, and those idempotent calls are not audited — otherwise the one event worth seeing would be buried under one row per worker per rollout.

deployment.conflict_rejected is the opposite case, and it records something that did not happen: an authorized caller tried to point an existing deployment ID at a different artifact, snapshot, mode or endpoint, and the server refused. Deployment IDs are what pinned runs resolve through, so redefining one would move those runs onto code nobody chose for them. Authorization logs allow/deny decisions and this call is allowed — the handler is what rejects it — so this is the only record that the attempt was made. Its version carries the digest that was refused; the one on record is readable from the deployment itself.

A re-registration writes projection.registered / schema.registered again rather than an .updated variant: both resources are addressed by name (and version, for schemas), so registering over one is a registration that clobbers — and the clobber is the thing worth seeing.

Function code, schema documents and consumer-group configs never appear in these payloads. As with secrets, the event struct has no field to hold them.

Secret values, KV values, and token material are structurally absent from these payloads — omitted at the event-struct level, not filtered at serialization.

The four kv.* events are off by default. Set IRONFLOW_AUDIT_KV_EMIT=1 to record them; they sit on a hot data path and will grow the audit table quickly.

The Settings page shows the effective policy for the current server. It distinguishes per-function workflow recording from server-wide operator controls: successful authorization capture, excluded allowed actions, KV capture, global payload redaction, global retention, and delivery health. The same read-only data is available at GET /api/v1/audit/settings. payload_redaction_enabled reports whether the policy is active, and payload_redaction_fields contains its normalized, sorted selectors. Server-wide values are read at startup and changes require a restart; the view does not offer edits.

Audit delivery is synchronous and best effort. A recorder attempts each row at most once and logs an error if that write fails. The request or workflow that emitted the record continues, so telemetry failure does not become execution failure. Automatic retries are disabled to avoid duplicate rows when a store write result is ambiguous.

Workflow and capacity audit rows can also drop before they reach the store, even on a healthy database, when a load passes the recorder’s limits. Do not use audit_events as a complete record of a run. See Audit rows can drop under load for the limits and how to find the cause.

With IRONFLOW_METRICS_ENABLED=true, alert on ironflow_audit_write_failures_total{source,event_type}, which counts each row that fails in the recorder. That page gives the alert expression. /ready also returns an audit warning, but the next successful audit write clears it, so it can show healthy while rows are lost. The warning is report-only so an idle replica remains available to recover after the store comes back.

DataPurpose and persistence
Execution auditOptional function-scoped lifecycle events for inspection, controlled by recording.
Durable step resultsPersisted outputs used to resume a run, even with recording: false.
Case stateApplication business facts, such as a ticket status, stored in the application’s chosen database or entity stream.
Projection-backed memory and curated knowledgeDerived state over explicit events. The application chooses what is suitable to retain and reuse as model context.

The Node agent’s llm.complete wrapper persists the completion returned by the provider closure as a durable step result, including returned metadata. Disabling audit recording does not prevent that persistence. Return only replay-required data from the closure and omit unnecessary raw or sensitive content.

Raw audit logs are not automatically appropriate or authorized model context. Projections derive views; they do not automatically provide curation, redaction, or authorization. Apply those policies before supplying data to a model.

Audit retention and payload redaction govern audit rows, not all execution data. Pruning or redacting audit events does not remove durable step results, case state, or memory. The global audit pruner applies as described below.

Recording is configured per-function. When recording is off, no function-scoped audit events are captured. Existing events are preserved.

Use recordingProfile when a function needs a narrower workflow capture boundary:

ProfileCaptured workflow events
allRun lifecycle, step, and saga compensation events
run_lifecyclerun.created and run.status_changed
stepsStep events and saga compensation events
empty or omittedLegacy behavior: recording: true means all; false means off

A non-empty profile enables recording even when the legacy boolean is omitted. The server returns the effective profile, so legacy enabled functions appear as all. Operator, authorization, resource, control-plane, and scanner events have their existing capture rules and are independent of this workflow profile.

Operator actions against the control plane also ignore it: run.cancelled, run.resumed, and every event in the Resources and Control-plane resources tables. They are not function-scoped application telemetry, so a per-function toggle must not be able to hide them.

Three more events ignore this toggle: capacity.lease.expired, capacity.segment.recovered and capacity.run.readmitted. They are emitted by the internal capacity scanner and orphan sweep, not by a function, and record unconditionally — they are platform concerns a tenant’s per-function toggle must not suppress. Everything else in the tables above respects the setting.

import { createFunction } from "@ironflow/node";
const processOrder = createFunction(
{
id: "process-order",
recordingProfile: "steps",
triggers: [{ event: "order.placed" }],
},
async ({ event, step }) => {
const validated = await step.run("validate", async () => {
return validateOrder(event.data);
});
// Step and saga compensation events are recorded; run lifecycle events are not.
return validated;
},
);

The per-function recordingRetention field (recording_retention in Python and the wire protocol) was removed. It never controlled deletion. Delete it from function definitions. The server-wide pruner applies uniformly by age. Set IRONFLOW_AUDIT_RETENTION_DAYS=0 to disable automatic audit pruning globally.

The server runs a daily pruner that deletes rows older than IRONFLOW_AUDIT_RETENTION_DAYS from the audit_events table. This protects single-VPS deployments from unbounded table growth.

EnvDefaultNotes
IRONFLOW_AUDIT_RETENTION_DAYS900 disables the pruner. Values between 1 and 6, above 36500, or that are not integers are rejected at boot (safety floor is 7).

The pruner runs once per day at 03:00 UTC and logs rows_deleted and duration on every pass. Multi-node clusters fire at the same wall-clock instant regardless of host TZ.


Navigate to Audit Log in the left sidebar to browse audit events across all runs. You can filter by:

  • Function — select a specific function from the dropdown
  • Event type — filter by step.completed, run.created, etc.
  • Run ID — search for events from a specific run

From any run detail page, click the Audit Log button to jump directly to that run’s audit events.

Terminal window
# View audit trail for a run
ironflow audit trail <run-id>
# Filter by event type
ironflow audit trail <run-id> --type step.completed
# Time range filtering
ironflow audit trail <run-id> --from 2025-01-01T00:00:00Z --to 2025-01-02T00:00:00Z
# Limit results
ironflow audit trail <run-id> --limit 100
# JSON output (for piping to jq, etc.)
ironflow audit trail <run-id> --json

Example output:

TIMESTAMP EVENT TYPE STEP ID PAYLOAD
10:23:01.123 run.created {"runId":"run-abc","functionId":"process-order"...}
10:23:01.145 step.started step-1 {"stepId":"step-1","stepName":"validate","stepType":"invoke"}
10:23:01.312 step.completed step-1 {"stepId":"step-1","output":{"valid":true},"durationMs":167}
10:23:01.315 step.started step-2 {"stepId":"step-2","stepName":"charge","stepType":"invoke"}
10:23:01.520 step.completed step-2 {"stepId":"step-2","output":{"charged":true},"durationMs":205}
10:23:01.522 run.status_changed {"runId":"run-abc","oldStatus":"running","newStatus":"completed"}
Total: 6 events
Terminal window
# Get audit events for a specific run
curl -H "Content-Type: application/json" -d '{"runId":"<run-id>"}' \
http://localhost:9123/ironflow.v1.AuditService/GetAuditTrail
# List audit events across all runs (with optional filters)
curl "http://localhost:9123/api/v1/audit?function_id=process-order&event_type=step.failed&limit=50"
# Paginate with cursor
curl "http://localhost:9123/api/v1/audit?cursor=<next_cursor>&limit=50"

Query parameters:

ParameterDescription
function_idFilter by function ID
run_idFilter by run ID
event_typeFilter by event type
fromStart timestamp (RFC3339)
toEnd timestamp (RFC3339)
limitMaximum events to return (default: 100)
cursorPagination cursor from previous response
client := ironflow.NewClient(ironflow.ClientConfig{
ServerURL: "http://localhost:9123",
})
result, err := client.GetAuditTrail(ctx, "run-abc123", ironflow.GetAuditTrailOpts{
EventType: "step.completed",
Limit: 50,
})
if err != nil {
log.Fatal(err)
}
for _, event := range result.Events {
fmt.Printf("%s %s %v\n", event.CreatedAt, event.EventType, event.Payload)
}
// Paginate
if result.NextCursor != "" {
nextPage, _ := client.GetAuditTrail(ctx, "run-abc123", ironflow.GetAuditTrailOpts{
Cursor: result.NextCursor,
})
// ...
}
from ironflow import IronflowClient, IronflowRPC
from ironflow.rpc.v1 import GetAuditTrailRequest
with IronflowRPC(server_url="http://localhost:9123") as rpc:
result = rpc.audit.get_trail(GetAuditTrailRequest(
run_id="run-abc123", event_type="step.completed", limit=50,
))
for event in result.events:
payload = event.payload_value.to_python() if event.payload_value is not None else event.payload.to_python()
print(event.created_at, event.event_type, payload)
if result.next_cursor:
next_page = rpc.audit.get_trail(GetAuditTrailRequest(
run_id="run-abc123", cursor=result.next_cursor,
))
# The global feed remains REST-backed.
client = IronflowClient(server_url="http://localhost:9123")
client.audit_list(function_id="process-order", event_type="step.failed",
from_="2026-01-01T00:00:00Z", limit=50)

The metadata column contains recorder-owned context. When the global payload policy removes fields, audit_redacted_fields contains their canonical public names as a sorted, comma-separated string. It never contains the removed values. There is no user-facing API for attaching custom metadata to an audit event.


The audit recorder is integrated directly into the Ironflow engine. When a function has recording: true, the engine writes an audit event to the database at each lifecycle point — run creation, status transitions, step starts, completions, failures, hot-patches, and saga compensations. These writes are best effort under load.

Event triggers run
│
▼
┌─────────────────────────────────┐
│ Engine executes function │
│ │
│ run.created ──► audit_events │
│ step.started ──► audit_events │
│ step.completed ──► audit_events│
│ run.status_changed ──► ... │
└─────────────────────────────────┘
│
▼
Query via API / CLI / Dashboard

Key characteristics:

  • Append-only — events are never modified or deleted, except by the retention policy and by an operator redaction (run.redacted, step.redacted), which replaces the payloads of the affected rows with a placeholder
  • Non-blocking — audit write failures are logged but never block workflow execution
  • Opaque IDs — treat an audit event ID as a string, and use created_at for order. Most IDs are ULIDs, but a run.created row has an ID derived from its run
  • Run creation time — a run.created row’s created_at is when the run was created, not when the row was written, so it reads as the run’s first event even if it lands late. A tool that polls with from set to the last time it saw can miss such a row, so overlap each poll and drop duplicates by ID
  • Per-function caching — the engine caches each function’s recording status to avoid repeated database lookups. A change applies at once on the node that took it and within about 30 s on other nodes, with no restart

In addition to workflow audit events, Ironflow can record an audit trail of authorization decisions and auth-related mutations (see the capture settings below). Use it to debug access issues and for security audits.

Event TypeWhenKey Payload Fields
authz.decision.allowedAuth middleware grants accessapi_key_id, action, resource, environment, method, path, decision, policy_id (conditional), impersonated_org_id (conditional)
authz.decision.deniedAuth middleware denies accessapi_key_id, action, resource, environment, method, path, reason, decision, policy_id (conditional), impersonated_org_id (conditional), webhook_source_id (conditional: the webhook source that was denied)
authz.decision.would_denyA policy set in monitor mode would have denied the check. The request was allowed.api_key_id, action, resource, environment, method, path, reason, decision, policy_set_version, policy_set_mode
authz.key.createdAPI key createdapi_key_id, key_name, org_id, actor_id
authz.key.revokedAPI key deleted or rotatedapi_key_id, key_name, org_id, actor_id, reason (conditional)
authz.role.changedRole created, updated, or deletedrole_id, role_name, org_id, change_type, actor_id
authz.policy.changedPolicy set written or rolled backpolicy_id, policy_name, org_id, change_type, actor_id, old_version, new_version, mode, old_mode (conditional), bypass, bypass_reason (conditional)

Auth events use run_id="" and function_id="" since they are not workflow-scoped. The metadata JSON includes org_id and api_key_id (for decision events) for filtering. actor_id lives in the payload. It is the ID of the API key, tenant user or platform user who made the change. An ID that starts with puser_ means a platform operator changed this organization’s own key or role. A platform key’s create, delete, rotate, and role-replacement rows are platform-scoped, including during impersonation, so they are not in this feed.

New HTTP decision payloads use a bounded route label in path, such as /api/v1/events/{id}. A refusal before routing uses the route the request was aimed at. A request that names no route uses unmatched. Registered catch-all routes retain their patterns, such as /api/v1/. ConnectRPC HTTP decisions use the service mount, such as /ironflow.v1.AgentToolsService/, so their path no longer identifies a method. Internal checks keep their fixed procedure names, such as eventtrigger.Emit. Resource IRNs retain target identifiers where supplied. Other audit fields can still contain caller-selected text.

For example, a new denial for GET /api/v1/events/evt_example can contain:

{
"action": "events:subscribe",
"resource": "irn:ironflow:org_default:proj_default_default:event:env_default:evt_example",
"method": "GET",
"path": "/api/v1/events/{id}",
"decision": "denied"
}

Older rows can contain concrete normalized paths such as /api/v1/events/evt_example. Existing payloads remain unchanged under normal retention, so a query can return both forms. Bounding the audit path does not change RBAC checks, resource IRNs, or the actual HTTP request view Rego evaluates.

A request refused for its credential writes an authz.decision.denied row with action auth:authenticate and reason set to the failure reason of ironflow_auth_failures_total (expired_key, revoked_session, inactive_user, bad_login or env_forbidden; see Authentication failures).

  • Only a principal the engine knows gets a row: an expired API key, a dashboard user who typed a wrong password at login or an incorrect current password during a password change, a dashboard or desktop session whose user or source API key was deleted (on a request that checks the session’s current principal: an authenticated API, ConnectRPC or WebSocket request, or the dashboard’s GET /api/v1/auth/validate session check), a session of a deactivated platform user, or a valid credential that asked for an organization or environment outside its scope. An unknown key, a forged token, an unknown login email or a platform session whose platform user was deleted writes no row, so guessing cannot fill the table. The counter and the rate-limited WARN log still record them. A dashboard page request checks only the session token’s signature and expiry, so a valid token of a deleted user is not refused there and leaves no row, count or log line; its next API call or session check is refused.
  • Each principal writes at most one row per minute on each node. The next row has suppressed, the number of refusals since the previous row that got no row. That count is reported only if the principal is refused again within 10 minutes; after 10 minutes without a refusal the node forgets it.
  • The row is stored in the principal’s own organization and resolved environment, never in an organization or environment outside its scope that the request named. If the refused principal has no resolved environment, as at dashboard login, the row goes to its organization’s oldest environment (the first created; env_default for the default organization). An organization with no environment gets no row. A platform principal’s row goes to env_default, like its other decision rows.
  • A failed platform login writes no row: it is counted and logged only.

The Rego policy set of an organization adds fields to decision rows. See Authorization policy sets.

  • policy_set_version and policy_set_mode appear on every decision row for which the set was consulted. They are absent when Layer 2 was not consulted, for example when RBAC denied the request.
  • For a deny, reason holds the reasons of the set, sorted and joined with ; . The text is cut at 1 KiB with a …(+N more) suffix. An evaluation failure reads policy evaluation error (<class>).
  • Denied rows are always recorded. The capture settings for allowed decisions do not apply to authz.decision.would_deny rows. Ironflow samples them, as the next bullet says.
  • Decision rows are stored in the environment of the request. A row for a request in env_prod does not appear in a view of env_default.
  • Sampling. A broad monitor-mode rule could write one row for every request. Ironflow writes at most one would_deny row for each combination of organization, action and first reason, every 10 seconds on each node. The counter ironflow_policy_would_deny_total counts every would-deny decision. Checks that a handler makes by itself, without an HTTP request, write would_deny rows too, with the same sampling.
  • A reason that includes request data, such as a user ID or a resource ID, is different for each request. Sampling treats each text as a new combination, so it cannot limit the rows. Use a fixed reason text in broad rules.
  • A write to the policy set is an authz.policy.changed row. A write that used the self-lockout bypass has bypass: true and a bypass_reason. The platform break-glass ironflow policy force-monitor also sets bypass: true and records its reason. When a write changes the mode, for example from enforce to monitor, the row also has old_mode with the previous mode. A change to the platform organization’s set is recorded in the platform scope, so read it via /api/v1/platform/audit. A force-monitor on a tenant set also writes a platform-scope row that names the operator.

To list would-deny rows, filter by the event type:

Terminal window
curl -s -H "Authorization: Bearer $IRONFLOW_API_KEY" \
"http://localhost:9123/api/v1/audit?event_type=authz.decision.would_deny"
Terminal window
# View auth audit trail for an organization
ironflow audit auth-trail --org <org_id>
# Filter by API key
ironflow audit auth-trail --org <org_id> --key <api_key_id>
# Filter by action
ironflow audit auth-trail --org <org_id> --action "functions:invoke"
# Time range
ironflow audit auth-trail --org <org_id> --from 2026-01-01T00:00:00Z --to 2026-01-02T00:00:00Z
# Limit results and JSON output
ironflow audit auth-trail --org <org_id> --limit 50 --json

Example output:

TIMESTAMP EVENT TYPE DECISION PAYLOAD
10:23:01.000 authz.decision.allowed allowed {"api_key_id":"key-1","action":"functions:invoke"...}
10:23:02.000 authz.decision.denied denied {"api_key_id":"key-2","action":"admin:manage","reason":"..."}
10:24:00.000 authz.role.changed {"role_id":"role-1","change_type":"created"...}
Total: 3 events

The GetAuthAuditTrail RPC is available on the AuditService:

Terminal window
# Using buf curl (ConnectRPC)
buf curl --data '{"org_id": "org-123", "limit": 50}' \
http://localhost:9123/ironflow.v1.AuditService/GetAuthAuditTrail

Request fields:

FieldDescription
org_idRequired. Organization ID to query
api_key_idFilter by API key
actionFilter by action (e.g., functions:invoke)
from_timestampStart timestamp (RFC3339)
to_timestampEnd timestamp (RFC3339)
limitMaximum events to return (default: 100)
cursorPagination cursor from previous response

Auth audit logging writes captured decisions synchronously to the audit_events table. Write failures are logged but do not fail the request.

By default, all decisions are captured. Operators can suppress successful reads or specific allowed actions through the authorization capture policy. Denied decisions remain captured.


  • Time-Travel Debugging — audit events power time-travel debugging, letting you inspect run state at any historical point in time
  • Workflows — learn about functions, steps, and execution modes
  • Sagas — compensation patterns that generate saga.compensation.* audit events
  • Debugging — use the TUI debugger alongside the audit trail

The tenant Audit Log supports inclusive From time and To time bounds, entered in UTC, an exact Authorization action such as functions:list, and an exact API key ID. Enter the key identifier, never its secret. These filters combine with function, run, and event types and apply to every page. Tenant queries always exclude platform records.

Filters are stored in the URL. Reloading, sharing the URL, or using browser back/forward restores the selected query and display filters. Clear filters shows the complete feed, including successful checks. Display categories and successful-check hiding still apply only to loaded pages.

GET /api/v1/audit accepts from, to, action, and api_key_id alongside function_id, run_id, event_type, limit, and cursor. Time bounds accept RFC3339 timestamps or YYYY-MM-DD. A date alone means midnight UTC, including the to bound; use a timestamp to include the rest of that day. Invalid bounds return HTTP 400. Pagination preserves the filtered total count.

The response includes retention_days, the effective policy loaded at server startup. The dashboard shows it with the results. Zero means global pruning is disabled; a positive value makes older records eligible for the daily prune. This policy applies to all audit records of every function.

Operators can configure capture through process environment variables. These settings apply across the server’s environments, with both YAML and flag-based startup, and require restarting every server process after a change.

VariableDefaultBehavior
IRONFLOW_AUDIT_AUTHZ_CAPTURE_SUCCESSFUL_READStrueSet to false to omit allowed decisions for actions ending in :read or :list.
IRONFLOW_AUDIT_AUTHZ_EXCLUDE_ACTIONSemptyComma-separated exact action names whose allowed decisions are omitted. Whitespace is trimmed; wildcards are not expanded.

For example, start with successful-read capture disabled and an explicit action exclusion:

Terminal window
IRONFLOW_AUDIT_AUTHZ_CAPTURE_SUCCESSFUL_READS=false IRONFLOW_AUDIT_AUTHZ_EXCLUDE_ACTIONS='functions:list,runs:read' ironflow serve

Denied decisions are always recorded, including for actions covered by either suppression setting. Key lifecycle, resource changes, and workflow recording are unaffected. Invalid boolean values fail startup. The defaults preserve previous capture volume; the dashboard’s Hide successful authorization checks control only changes display and never removes stored records.

Choose Export JSONL in Audit Log to download every matching page, including pages you have not loaded. The export preserves the selected environment, function, run, event types, inclusive time bounds, authorization action, API key ID, category, and Hide successful authorization checks setting. Changing filters while an export runs affects the page and future exports. It does not change the export in progress. Cancel export stops the download.

The server fixes the upper time bound at export start, or at your selected To time if earlier. Records timestamped after that bound are excluded. This is a timestamp-bounded scan, not a database snapshot: an earlier-timestamped record committed during export may be included if visible before its page is read. Retention pruning may remove records before the export reads them. The file’s manifest records the effective filters, environment, and cutoff.

The .jsonl file contains a manifest line, one record envelope per audit event with the complete stored event under data, optional heartbeat lines, and a completion footer. Payloads and metadata retain their stored structure and redaction. The footer includes counts.audit_events and body_sha256, a SHA-256 of all bytes before the footer. The dashboard checks the footer and record count before saving; it does not independently verify the checksum. An interrupted or failed request shows an error and offers no partial file. Retry starts a new export with the current filters and a new cutoff.

The dashboard holds the download in browser memory until it completes. Use a narrower time range for large exports, or consume the HTTP stream directly:

Terminal window
curl --fail-with-body \
-H "Authorization: Bearer $IRONFLOW_API_KEY" \
-H 'X-Ironflow-Environment-ID: env_default' \
'http://localhost:9123/api/v1/audit/export?category=workflow&hide_successful=true&from=2026-09-01T00:00:00Z&to=2026-09-10T23:59:59Z' \
-o audit-export.jsonl

GET /api/v1/audit/export requires the same runs:read permission as Audit Log. It exports tenant records from the request environment; query parameters cannot select another environment or platform scope. It accepts the list endpoint’s function_id, run_id, comma-separated event_type, from, to, action, and api_key_id filters, plus category=all|workflow|security|administration and hide_successful=true|false. Direct API requests default to all categories with successful checks included. from and to accept RFC3339 timestamps or YYYY-MM-DD for midnight UTC. cursor and limit are ignored so every matching page is exported.

HTTP errors before streaming use an error status. A failure after streaming starts uses a JSONL error envelope instead of the completion footer, so HTTP 200 alone does not prove completion. Direct API consumers must require a footer whose count matches the event records; verify its checksum when checking file integrity. See Exporting data for the shared JSONL envelope format.

cluster.log_level.requested is a platform-scope event for POST /api/v1/cluster/log-level. Its payload records the command ID, requested level, issue and expiry times, actor ID, organization, and outcome: "authorized_attempt". The store must accept this row before the node changes its threshold or broadcasts the command. It does not assert cluster-wide success. The HTTP response separately lists authenticated applied node IDs.