Appearance
Audit trail
Every state change a person, an integration or the system makes is appended to the tenant's audit trail (audit_events): who (actorType, actorId), what (action), on which resource (resourceType, resourceId), when (occurredAt), a JSON payload with the details and, for a change made through the API, the request that made it (meta). Rows are append-only and tenant-scoped (row-level security). The API reads them with GET /audit-events and GET /audit-events/:id (audit:read: admins and analysts) and exports them with GET /audit-events/export and on a schedule (audit:export: admins only). The integration role has neither. What a payload shows depends on the reader's permissions: see Payloads by caller.
Events
Actions are dotted, lower-case verbs. actorType is one of user (a person; actorId is the Zitadel user id), service (a service user calling the API with a token), applicant (the holder of a signed collection link), app (an install: its vendor's webhook relayed by the API, its circuit opened or closed by the worker; actorId is the install's name), system (seeds and migrations), workflow (the worker acting on behalf of a run) or operator (the deployment's operator acting through pnpm ops; actorId is the name they gave with --by, which nothing checks).
Subjects, runs and decisions
| Action | Resource | Appended by | Payload |
|---|---|---|---|
subject.created | subject | POST /subjects | { kind, externalId } |
subject.updated | subject | PATCH /subjects/:id | { paths }: the paths that changed (data.address.city, tags, status), never their values |
subject.exported | subject_export (the tenant id) | GET /subjects/export | { filter, rows, truncated } |
workflow.run.started | workflow_run | POST /workflow-runs and POST /workflow-runs/:id/replay (workflow-engine client) | { definitionKey, definitionVersion, subjectId, temporalWorkflowId, temporalRunId, taskQueue, trigger, correlationId, replayOf } (the last two when set) |
workflow.run.cancelled | workflow_run | POST /workflow-runs/:id/cancel, or the worker when the run's form was abandoned | { reason, closedCaseIds, withdrawnSubmissionIds, failedCallbackIds } (the app sessions that waited for a webhook, failed) |
workflow.run.completed | workflow_run | the worker, when a run finishes | { definitionKey, definitionVersion, subjectId, decisionId } |
workflow.run.failed | workflow_run | the worker, when a run fails | { definitionKey, definitionVersion, subjectId, decisionId, error } |
workflow.rules.evaluated | workflow_run | an evaluate_rules step | { ruleKeys, ruleSetKey, ruleSetVersion, outcome, riskScore, results } |
decision.emitted | decision | an emit_decision step | { outcome, riskScore, source, subjectId, workflowRunId, caseId, ruleSetKey, ruleSetVersion } |
events.ingested | events | POST /events | { accepted, duplicates } |
backtest.started | backtest | POST /backtests | { ruleKey, ruleVersion, compareWithRuleSetKey } or { workflowKey, workflowVersion }, with { sample, temporalWorkflowId, taskQueue } |
results holds one rule result per rule (ruleId, ruleKey, ruleVersion, severity, weight, outcome, score, reasons, details, durationMs, shadow). Its details are what the rule compared (a list lookup's value, a velocity rule's group key, a comparison's actual value) or an app rule's whole output.
Collection flows and documents
| Action | Resource | Appended by | Payload |
|---|---|---|---|
collection.submission.created | collection_submission | POST /collection-submissions, a wait_for_collection step | { flowKey, flowVersion, subjectId, workflowRunId, hasLink }, plus linkTtlSeconds from the API |
collection.submission.submitted | collection_submission | POST /collection-submissions/:id/submit | { flowKey, flowVersion, subjectId, workflowRunId } |
collection.submission.handoff_started | collection_submission | GET /collection-submissions/:id/handoff (the applicant, sent to the vendor's hosted page for the session their run waits for) | { workflowRunId, callbackId, installName, appName, host }: the page's host (with its port when not the default), never its URL |
collection.submission.link_issued | collection_submission | POST /collection-submissions/:id/link | { subjectId, expiresAt } |
collection.submission.link_refreshed | collection_submission | POST /collection-submissions/:id/refresh (the applicant, keeping a live link alive) | { expiresAt } |
collection.submission.link_requested | collection_submission | POST /collection-submissions/:id/resume-link (the applicant, asking for a new link); raises submission.link_requested | { subjectId, flowKey, workflowRunId, caseId } |
collection.submission.withdrawn | collection_submission | POST /collection-submissions/:id/withdraw, deciding or closing a case with an open request, cancelling its run | { caseId, reason, workflowRunId } (reason: withdrawn, case_decided or run_cancelled) |
collection.submission.expired | collection_submission | the run, when a request for information expires unanswered; the worker, for a form nobody saved for the tenant's collection.abandonAfterDays | { caseId, subjectId, workflowRunId }, plus reason: 'abandoned' and abandonAfterDays from the worker |
document.created | document | POST /documents (upload URL issued), or a request for information copying an answered file | { fileName, contentType, sizeBytes, subjectId, submissionId, fieldKey }; a copy has { fieldKey, fileName, sizeBytes, subjectId, copiedFrom } |
document.uploaded | document | upload finalised | { sha256, contentType, sizeBytes } |
document.rejected | document | finalisation or the scan refused the file | { reason, code }, plus declaredContentType at finalisation |
document.deleted | document | DELETE /documents/:id (the applicant, while the form is open) | { fileName, submissionId, fieldKey, status } |
document.scanned | document | the malware scan finished | { engine, verdict, reencoded }, or { engine, verdict, signature } when infected |
document.downloaded | document | GET /documents/:id/download | { fileName, expiresAt } |
The signed collection link is never audited: hasLink records that one was issued. The follow-up of a request for information records its caseId and the submission it was copied from (followUpOf) on collection.submission.created.
Cases
| Action | Resource | Appended by | Payload |
|---|---|---|---|
case.created | case | a create_case step | { type, priority, subjectId, workflowRunId, dueAt } |
case.assigned / case.unassigned | case | assign, claim, unassign | { assigneeId, previousAssigneeId, status } |
case.decided | case | POST /cases/:id/decide, or the run when a request for information expires | { decisionId, outcome, reasons, subjectId, workflowRunId } |
case.info_requested | case | POST /cases/:id/request-info | { submissionId, message, expiresAt, workflowRunId } |
case.info_received / case.info_withdrawn | case | the run, once the applicant answered or the request was withdrawn | { caseId, submissionId, workflowRunId, dueAt } |
case.closed | case | POST /cases/:id/close, the run closing it, or cancelling its run | { reason, decisionId, workflowRunId }, or { caseId, reason, workflowRunId } from the run |
reason_codes.updated | reason_codes (the tenant id) | PUT /reason-codes | { codes }: the catalogue's codes in order |
case.sla.breached | case | the SLA timer of the run | { caseId, priority, workflowRunId } |
case.note.added / case.note.edited / case.note.deleted | case | case notes | { noteId, length } when added, { noteId, authorId } otherwise |
Definitions and governance
| Action | Resource | Appended by | Payload |
|---|---|---|---|
<kind>.version.created | <kind> | PUT <prefix>/:key | { key, version, note } |
<kind>.version.updated | <kind> | PATCH <prefix>/:key/versions/:version | { key, version, note } (every save in place, autosaves too) |
collection_flow.previewed | collection_flow | POST /collection-flows/:key/versions/:v/preview | { key, version, expiresAt } |
<kind>.published | <kind> | POST <prefix>/:key/publish, or an approval | { key, version } |
definition.approval.requested | definition_approval | POST .../versions/:v/request-approval | { kind, key, version, comment } |
definition.approval.approved | definition_approval | POST .../versions/:v/approve | { kind, key, version, comment } |
definition.approval.rejected | definition_approval | POST .../versions/:v/reject | { kind, key, version, comment } |
definition.approval.withdrawn | definition_approval | POST .../versions/:v/withdraw | { kind, key, version, comment } |
definition.approval.commented | definition_approval | POST /approvals/:id/comments | { kind, key, version, commentId, comment } |
rule_definition.dry_run | rule_definition | POST /rule-definitions/:key/dry-run, per version | { key, version, outcome } |
definitions.imported | policy_pack | POST /definitions/import | { name, version, counts, keys, installs, publish, approvalRequired }; installs is [{ name, app }], one per install the pack declares |
definitions.exported | policy_pack | GET /definitions/export | { name, version, counts, keys } |
<kind> is workflow_definition, rule_definition, rule_set or collection_flow. The publish that an approval performs adds approvalId to its payload. A policy pack import also appends the per-item events above (<kind>.version.created with the pack name as pack, then <kind>.published, also with pack, or definition.approval.requested with publish=true, and the list events); the pack events themselves use the tenant id as the resource id.
Lists, apps, webhooks, service users, tenant and the audit trail itself
| Action | Resource | Appended by | Payload |
|---|---|---|---|
list.created / list.updated | list | PUT /lists/:key | { key, kind, caseInsensitive } |
list.deleted | list | DELETE /lists/:key | { key, entryCount } |
list.entries.added | list | list entry routes | { key, added, skipped } |
list.entries.removed | list | list entry routes | { key, removed, requested } |
list.imported | list | POST /lists/:key/import (CSV) | { key, added, skipped, invalid } |
app.callback.received | app_callback | vendor webhooks (POST /webhooks/app-installs/…) | { installName, appName, externalId, status, eventId } (never the payload) |
app.uploaded | app_version | POST /apps/uploads | { name, version, sha256, sizeBytes } |
app.provenance.recorded | app_version | PUT /apps/:name/versions/:id/provenance | { name, version, kind, signer, source, transparencyLogIndex, trusted, trustedSigner }, trust as the policy judged it when it was recorded |
app.published | app_version | POST /apps/:name/versions/:id/publish | { name, version, sha256 } (a direct publish: no approval or review required) |
app.published | app_version | the operator's approval of a review (pnpm ops) | { name, version, sha256, via: 'review' }, actor operator |
app.approval.requested | app_version | POST /apps/:name/versions/:id/publish | { name, version, approvalId } (when the tenant requires approvals for apps) |
app.approval.decided | app_version | POST .../versions/:id/approve / reject | { name, version, approvalId, decision } |
app.review.requested | app_version | POST .../versions/:id/publish / approve | { name, version, reviewId }, plus approvalId after an approval (when the tenant is under review) |
app.review.decided | app_version | pnpm ops: a review decided, or reviews off | { name, version, reviewId, decision, comment }, actor operator; raises the webhook event of that name |
app.withdrawn | app_version | DELETE /apps/:name/versions/:id | { name, version, sha256 }, plus approvalId and reviewId for the request and the review it closed |
app.withdrawn | app_version | the operator's withdrawal (pnpm ops) | { name, version, sha256, reason, via: 'operator' }, actor operator |
app.module.blocked | app_install | pnpm ops apps block, per install it reaches | { name, appName, versionId, version, sha256, scope, reason }, actor operator; raises the webhook event |
app.module.unblocked | app_install | pnpm ops apps unblock, per install it reached | as app.module.blocked, with the operator's note instead of the reason |
app.installed | app_install | POST /app-installs | { name, appName, versionId, version, enabled, autoUpgrade, configKeys, hosts, hostsAdded, hostsRemoved, missingSecrets, readsDocuments, receivesWebhooks } |
app.configured | app_install | PUT /app-installs/:install | as app.installed, plus superseded (the id of the change it withdrew) |
app.install.change.requested | app_install | POST or PUT /app-installs…, answered 202 | as app.installed (the state the save requests) plus changeId, newInstall, origin, needsAdded, handlesAdded, superseded and switchedOff |
app.install.change.requested | app_install | the API's automatic upgrades, per held move | as a save's, origin: 'auto_upgrade', plus fromVersionId, fromVersion and rollback, actor system |
app.install.change.decided | app_install | POST …/changes/:id/{approve,reject,withdraw} | { name, appName, changeId, decision, newInstall, origin, versionId, hosts, hostsAdded, needsAdded, handlesAdded } and the outcome (below) |
app.install.upgraded | app_install | the API's automatic upgrades, per move applied | { name, appName, fromVersionId, fromVersion, toVersionId, toVersion, automatic: true, rollback, hostsRemoved }, actor system; raises the webhook event |
app.install.upgraded | app_install | approving an automatic upgrade's change | as above with automatic: false and the changeId; raises the webhook event |
app.install.documents.enabled / .disabled | app_install | a save, an approved change or an automatic move | { name, appName, versionId, version, documentsFromConfig, hosts }: the install's calls read the tenant's documents from now on, or no longer do; the actor is who saved, who approved, or system |
app.install.renamed | app_install | POST /app-installs/:install/rename | { from, to, appName, aliasKept, aliasReclaimed, rewrite, definitions, skipped } |
app.install.alias.removed | app_install | DELETE /app-installs/:install/aliases/:alias | { name, alias, appName, force }, plus definitions and runs when it was still in use |
app.uninstalled | app_install | DELETE /app-installs/:install | { name, appName, versionId, failedCallbacks, secretsRemoved } |
app.tested | app_install | POST /app-installs/:install/test | { name, appName, action, status, durationMs } (no input or output) |
app.secret.stored | app_install | POST /app-installs/:install/secrets/:secret | { name, appName, secret } (never the value) |
app.secret.revoked | app_install | DELETE /app-installs/:install/secrets/:secret | { name, appName, secret } |
app.circuit.opened / app.circuit.closed | app_install | a call that moved the install's circuit (the worker) | { install, app, failures }; opened raises the webhook event |
app.circuit.configured | app_install | PUT /app-installs/:install/circuit | { install, app, previous, settings }, each { failureThreshold, openSeconds } (null for the deployment's) |
app.circuit.reset | app_install | POST /app-installs/:install/circuit/reset | { install, app, state, failures }: what the reset cleared |
app.limits.updated | app_install | PUT /app-installs/:install/limits | { name, appName, currency, prices, budgets, previous }, previous null for the first limits |
app.cache.cleared | app_install | DELETE /app-installs/:install/cache | { install, app, reason: 'manual', entries }: the live cached values it removed, whatever they were (never a value) |
app.cache.cleared | app_install | a secret stored or revoked, a config change or a move to a version | { install, app, reason, entries }, reason secret, config or version, only when a live value went; the actor is who saved, approved or stored, or system for an automatic upgrade |
app.quota.exceeded | app_install | a call a quota or a budget refused (a run's, or a test call) | { install, app, scope, metric, period, max, used, currency, periodStart, resetsAt }, once per limit and period; raises the webhook event |
webhook_endpoint.created | webhook_endpoint | POST /webhook-endpoints | { url, events, enabled }: the URL masked (below), never the secret |
webhook_endpoint.updated | webhook_endpoint | PUT /webhook-endpoints/:id | { changed, url, events, enabled }, the URL masked |
webhook_endpoint.deleted | webhook_endpoint | DELETE /webhook-endpoints/:id | { url }, masked |
webhook_endpoint.tested | webhook_endpoint | POST /webhook-endpoints/:id/test | { delivered, responseCode, latencyMs } |
webhook_endpoint.secret.rotated | webhook_endpoint | POST .../rotate-secret | { previousSecretExpiresAt } |
webhook_delivery.retried | webhook_delivery | POST /webhook-deliveries/:id/retry | { endpointId, event, status, attempts } |
service_user.created | service_user | POST /service-users | { name, role, purpose } |
service_user.updated | service_user | PATCH /service-users/:id | { name } and what changed: purpose, enabled |
service_user.key.created | service_user | POST /service-users/:id/keys | { keyId, expiresAt } (never the token) |
service_user.key.revoked | service_user | DELETE /service-users/:id/keys/:keyId | { keyId } |
tenant.settings.updated | tenant | PUT /tenants/me/settings | { changed }: the dotted paths that changed, never the values |
tenant.suspended / tenant.resumed | tenant | POST /tenants/me/suspend / resume | { reason } / {} |
tenant.links.revoked | tenant | POST /tenants/me/revoke-links | { validAfter, revokedLinks } |
tenant.app_review.set | tenant | pnpm ops: the tenant's review turned on or off | { appReview }, actor operator |
tenant.app_quota.set / .removed | tenant | pnpm ops tenants quota: one of the tenant's quotas set or removed | { metric, period, max }, actor operator |
team.user.invited | team_member | POST /team/invitations | { email, role } |
team.user.role_changed | team_member | PUT /team/users/:id/role | { from, to } |
team.user.deactivated / .reactivated | team_member | POST /team/users/:id/(de|re)activate | {} |
team.invitation.resent / .cancelled | team_member | the invitation routes | {} |
audit.exported | audit_export | the export and scheduled exports | { format, filter, rows, sha256 }; scheduled: scheduleId, runId, fileName |
audit.export.downloaded | audit_export_run | POST /audit-export-runs/:id/download | { scheduleId, fileName, rows } |
audit_export_schedule.created / .updated | audit_export_schedule | the schedule routes | { name, cron, filter, format, enabled } |
audit_export_schedule.deleted | audit_export_schedule | DELETE /audit-export-schedules/:id | { name } |
An app_install event's resource id is the install's id; its payload names the install (name, or install for a circuit, a cache or a quota) and the catalogue app it runs (appName, or app). An install created again under a removed one's name has a new id, so the trail keeps the two apart, and a renamed install keeps its id, so its events before and after app.install.renamed read as one install's under two names. A rename's definitions are the versions its rewrite wrote ({ kind, key, version, status }), skipped those it left for a newer draft, and aliasReclaimed the former name the install took back, when it did.
A save records the config's keys, never its values, and the hosts the install calls with it (hosts), those it adds and approved (hostsAdded) and those it drops (hostsRemoved), whether it applied (app.installed, app.configured) or waits for another administrator (app.install.change.requested, which also raises the webhook event of that name). secretPlaces lists each secret variable a step may use with its places ([{ secret: 'apiKey', places: ['path'] }], from the variables' stepPlaces), names and places only, and is absent when no variable grants any. superseded names the change a newer save withdrew, and switchedOff marks a waiting save that turned the install off at once. app.install.change.decided records the decision on a waiting change: an approval adds whether the install is enabled and its missingSecrets, and a rejected or withdrawn new install adds installRemoved and secretsRemoved. A save records autoUpgrade, whether the install upgrades automatically after it, and whether calls through the state it asks for would read the tenant's documents (readsDocuments) and receive its vendor's webhooks (receivesWebhooks).
app.install.documents.enabled and .disabled record the install's documents switch, beside the event of the write that turned it: a save (a new install read none before, and one that waits for its first approval is stored with its config), an approved change, or an automatic move (as system). They name the version the install runs after it, the config field that holds the switch (documentsFromConfig, absent for a version that reads documents on every call) and the hosts the documents may go to. Documents never hold a save for an approval. A save of an install that waits leaves it serving as it was, so a switch it turns on is recorded when its change is approved; one that turns documents off (its config, or a version that reads none) turns them off at once in the config the install serves with, recorded with the save.
The platform's moves of an install that upgrades automatically are the system's: a move that adds nothing is app.install.upgraded (automatic: true, rollback when it moved down off a withdrawn or blocked version), and one that adds hosts or capabilities is a change requested with origin: 'auto_upgrade', naming what it adds (hostsAdded, needsAdded such as documents, handlesAdded such as webhook). Approving that change records app.install.upgraded too, with automatic: false, beside the decision. fromVersionId is the version a save may move the install back to; superseded names an earlier automatic change a newer target withdrew.
A version's review and its blocks are the operator's. A tenant under review (appReviewrequired on GET /tenants/me) records app.review.requested where it would have published, and the operator's decision arrives as app.review.decided (and app.published with via: 'review' on approval); turning the tenant's reviews off closes those pending as withdrawn, each recorded the same way. A withdrawal by the tenant closes its pending review without a decision of the operator's. A block of a module or of an app is recorded on every install it reaches, in that install's tenant, with the reason the operator gave, and its lifting the same way; the operator's own trail of these actions (operator_audit_events) is the instance's, which no tenant reads.
An endpoint's URL can hold the receiver's token in its path or its query (a Slack-style incoming webhook), so the webhook_endpoint.* events record its origin with /•••• for a path and ?•••• for a query (https://hooks.example.com/••••?••••); GET /webhook-endpoints/:id answers the whole URL to webhooks:read.
List entry values are never audited, only their counts; secret values, webhook signing secrets and key tokens never are.
Request metadata
Every row written while the API serves a request carries meta: { requestId, route, ip, userAgent }, the request id the response's x-request-id header names, the method and route pattern (POST /cases/:id/decide), the client's address and its user agent (300 characters at most). The auth hook puts them on the request's tenant context and the audit writer stores them with the row, so every route records them without doing anything. Rows the worker writes (workflow steps, circuits, scheduled exports) have no meta. The IP and user agent are personal data: callers without subjects:pii read them as null.
Payloads by caller
GET /audit-events, GET /audit-events/:id, the export and the audit entries of GET /subjects/:id/timeline pass every payload through presentAuditEvent (apps/api/src/routes/redact.ts), the rule the entity routes follow (see Admin console) applied by key, since a payload has no fixed shape. Stored rows are never changed. Admins hold runs:context and subjects:pii and read every payload as stored; analysts hold neither; the integration role cannot read the trail.
| Class | Fields | Without the permission |
|---|---|---|
| Identifiers, statuses, flags and counts | subjectId, workflowRunId, caseId, decisionId, noteId, approvalId, reviewId, commentId, assigneeId, externalId, eventId, outcome, riskScore, priority, status, verdict, hasLink, contentType, sizeBytes, accepted, added, rows, sha256, timestamps | as stored |
| Definition keys, versions and settings | key, version, kind, ruleKeys, ruleSetKey, flowKey, definitionKey, workflowKey, pack, keys, counts, sample, configKeys, missingSecrets and secretPlaces (names), host, hosts, hostsAdded and hostsRemoved (hostnames, with a port that is not the default), embedOrigins | as stored |
| Run context, app input and output, rule details, submission data | every value under a context, data, details, input or output key, at any depth: today the details of each workflow.rules.evaluated result | "[redacted]" without runs:context, keys and array lengths kept |
| Personal data | values under a personal-data key anywhere else (emails, phone numbers, identity and account numbers, dates of birth, street lines) | masked without subjects:pii |
| Free text | rule result reasons, the reasons messages of case.decided (a reviewer's note is manual.note), approval and review comment, the reason of case.closed, document.rejected and the operator's blocks (and their note), fileName, signature | as stored |
Free text is returned as stored, so it can still show what the redaction hides: a list lookup's reason quotes the value it found on the list, and a reviewer's note is whatever they typed. No writer stores a signed collection link or a secret value.
Filters
GET /audit-events returns events newest first (occurredAt desc, id desc) and accepts:
| Parameter | Meaning |
|---|---|
action | prefix match: case. is every case event, case.note.added that one action |
actorId, actorType | exact match (actorType: one of the seven types above) |
resourceType, resourceId | exact match; together they give one resource's history |
from, to | ISO timestamps, both inclusive |
limit, offset | paging (the repository clamps limit) |
The response is { items, total, totalIsEstimate }: total counts the events matching the filters, ignoring limit and offset (one count(*) with the same conditions), so a reader can page with "of n" and know an export's row count before downloading it. The count stops at 100,000 rows: past that total is 100,000 and totalIsEstimate is true ("100,000+" in the console), so a page over the whole log costs at most that many index entries. The export takes the same filters (without paging) plus format.
GET /audit-events/:id (audit:read) returns one event with two additions, and answers 404 when the event does not exist or belongs to another tenant. It takes the list's filters: neighbors.newerId and neighbors.olderId are the events listed just above and below it under them (the console's ↑ and ↓ work across pages). related names the case, decision, run and subject the event concerns: from its resource, the ids its payload holds and what those records point at (a case's run and subject, a decision's case). Its payload is presented like the list's (see Payloads by caller).
GET /audit-events/count (audit:read) takes the same filters and answers { total, totalIsEstimate, approxBytes: { csv, jsonl } }, an export's row count and size estimated from the newest 200 matching events encoded as the export encodes them.
Saved views
GET /audit-views, POST /audit-views ({ name, filters }, the filter row as typed; a view of the same name is replaced) and DELETE /audit-views/:id (audit:read) keep a user's named filter sets, at most 50 each. They are per user and not audited.
Exports
GET /audit-events/export?format=csv|jsonl&<filters> streams every matching event, oldest first, without loading them into memory (keyset pages of 1000). The response is an attachment named audit-<from>-<to>.<ext> (UTC timestamps like 20261002T101500Z; start when there is no from, the request time when there is no to), or <filename>.<ext> when filename (letters, digits, ., _ and -, 100 at most) names it.
- CSV (
text/csv, RFC 4180, CRLF line ends): a headeroccurredAt,id,actorType,actorId,action,resourceType,resourceId,payload,meta, then one row per event.payloadandmetaare JSON strings (metaempty for rows the worker wrote). Fields holding a comma, a quote or a line break are quoted and inner quotes doubled. - JSON Lines (
application/x-ndjson): one object per event with the same keys,payloadandmetaas JSON.
Payloads are presented for the caller as on the list. Only admins hold audit:export, and they hold runs:context and subjects:pii, so an export carries every payload as stored; a role granted audit:export without those permissions would export what it may read, and the digests below cover those bytes.
Digests and the manifest line
The last line of every export is a manifest:
- CSV:
# sha256=<hex> rows=<n> - JSONL:
{"_manifest":{"sha256":"<hex>","rows":<n>}}
sha256 is the SHA-256 of every byte before the manifest line (header included) and rows the number of events. A file whose last line is not a manifest was cut short. In addition the response declares Trailer: X-Export-Sha256 and sends the SHA-256 of the whole body, manifest included, as an HTTP trailer, and X-Export-Rows with the number of events; trailers are often dropped by proxies and by curl, which is why the manifest exists.
Verify a download:
bash
curl -sS -H "authorization: Bearer $TOKEN" \
"$API_URL/audit-events/export?format=csv&from=2026-10-01T00:00:00Z" -o audit.csv
tail -n 1 audit.csv # # sha256=<hex> rows=<n>
LC_ALL=C sed '$d' audit.csv | shasum -a 256 # must equal <hex>
# Linux: head -n -1 audit.csv | sha256sum
echo $(( $(LC_ALL=C sed '$d' audit.csv | wc -l) - 1 )) # data rows (minus the header) = <n>For JSONL the row count is sed '$d' audit.jsonl | wc -l (no header), and the manifest is read with tail -n 1 audit.jsonl | jq ._manifest.
Each completed export is itself audited as audit.exported with { format, filter, rows, sha256 } (the manifest digest). That row is appended after the last byte of the export, so it is never part of it. An export the client abandons midway is not audited.
Scheduled exports
GET, POST /audit-export-schedules and GET, PUT, DELETE /audit-export-schedules/:id (audit:export) manage exports that run on a schedule: { name, cron, filter, format, enabled }, with cron five fields in UTC and filter the list's filters without from and to. Each schedule is a Temporal schedule (audit-export:<tenant>:<schedule>, one run at a time, paused while disabled) that starts the auditExportSchedule workflow on the backtest queue. A run exports the events from where the last successful run's window ended (the first run: from the schedule's creation) to ten seconds before it started, a half-open window, so consecutive runs hold every event once. The file is encoded as the export route encodes it, manifest line included, with payloads as stored, and written to the deployment's object storage as audit-exports/<tenant>/<schedule>/<name>-<from>-<to>.<ext>; a run is audited as audit.exported with the window, scheduleId, runId and fileName.
GET /audit-export-schedules/:id/runs lists the newest 20 runs (window, status, rows, bytes, digest, error) and POST /audit-export-runs/:id/download answers a five-minute link to a run's file, audited as audit.export.downloaded. A failed run (storage refused, a file over 256 MiB) is recorded with its error, shows on Home's attention list for a day, and leaves its window to the next run. Schedules need object storage and Temporal (503 otherwise), and a tenant has ten at most. Changes are audited as audit_export_schedule.created, .updated and .deleted; deleting a schedule keeps the files already written.
Deferred
- Integrity chain. A per-tenant hash chain over audit rows (each row carrying the hash of the previous one), so tampering with stored rows is detectable, is deferred. Exports carry digests now, which proves a file is complete and unchanged since it was downloaded, not that the rows were never edited in the database.
- Retention. Audit rows are kept indefinitely; per-tenant retention policies (and exporting before purge) are deferred.
- Scheduled export destinations. Scheduled exports go to the deployment's object storage, in UTC, at most 256 MiB a file; a tenant's own bucket, larger files and tenant time zones are deferred.
All three are recorded in docs/plans/deferred.md.