Skip to main content

Audit Trail

An audit trail lets you show a regulator or an auditor who did what, under which role, without relying on the requester's own account of the request.
Audit trail: two records, two different proofs.On chain, a write is provable only as far as which smart account signed it. Off chain, the platform's audit trail binds that same request to the human actor, the role they held at the time, and the route they called, written as one record per covered request. The trail proves who acted under what role, on which route, with what outcome. It does not prove which party a delegated actor was standing in for.Audit trail: two records, two different proofs.The same figure as the desktop variant, drawn portrait for phone widths. A chain signature proves which smart account signed. The off-chain audit trail writes one record per covered request, binding it to the human actor, their role at the time, and the route. Below the record sit its two halves: what it proves, and what it does not prove, which is the party a delegated actor was standing in for.

The trail binds a request to the actor and role that made it, not to the party a delegated actor stood in for.

  • Request middleware writes the trail from each route's declared audit disposition, so a route is covered from the moment it is written.

  • A covered mutation that cannot be recorded does not run. Authentication writes, which the authentication server records itself, are the exception: they fail open, so a trail outage cannot lock every operator out. Most sensitive reads that cannot be recorded are still served, with the failure logged as an error. Six routes declare that they require the entry and are refused instead.

  • Under a delegated authority the trail names the operator as the actor and the participant the request acted as.

Why the chain cannot answer who acted

A signature on a blockchain transaction proves which smart account sent it. It says nothing about which person sat behind that account, which role they held, or whether that step was theirs to take. A regulator asking who approved a compliance override, or an auditor confirming that only cleared staff opened a customer's file, is asking about a person and a permission, and a chain answers about a wallet.

A denied request, a rejected submission, a configuration edit and a sensitive read produce no transaction, so a chain-only investigation finds nothing.

The audit trail binds every covered state-changing or sensitive request to the human actor, their platform role and organisation role at that moment, and the outcome, whether or not the request ever reached the chain. It does not record which on-chain role grant authorised the act. The trail is read back through the same part of the platform that already produces what a regulator must be told, which is why it is one of the reporting obligation export's own report kinds, covered later on this page.

Why this trail covers the whole platform, not one asset's life

Asset Lifecycle indexes a different trail: every stage of one instrument's life, from design through issuance, servicing, and retirement, against that same asset's own indexed record, which is what makes that instrument's whole life auditable from one place. That trail answers whether an act on that instrument was authorised and left a record.

The audit trail answers a wider question. It covers every state-changing or sensitive request across the platform, regardless of whether an asset is even involved. Signing in, resetting a password, creating an API key, and enabling two-factor authentication all get an entry, described in the section below, alongside every transfer, coupon run, and trade that touches an asset. An entry names the actor and the role they held. It names an asset only when the request itself acted on one.

What the trail covers, and what it leaves out

Application-level write restrictions do not make the database itself a write-once archive.

What is logged: every state-changing request, and every read whose occurrence itself matters, tied to the person who made it, the role they held at that moment, their tenant, and the outcome. Ordinary reads are not logged. Logging every read would make the trail itself a privacy problem.

What is immutable: the application never updates or deletes an audit row. A database owner still could, and the hash chain and its on-chain anchor would show it. A pending row never changes. The outcome is a second append-only row linked to the pending row. The database refuses a change to a recorded audit row. The only write it accepts on an existing row fills in the hash of a row recorded before hashing existed, and only when that hash matches the row. The trail shows the two rows as one entry that keeps the pending row's identifier and start time. For every row, an organisation's or the platform's, the database refuses a delete unless the row's day is anchored. It does not check the retention window, which only the retention job applies. Each row is linked to the previous row by a SHA-256 hash. The platform's integrity check can report the first broken row, and a reviewer who compares an export with the on-chain anchor can detect a change the database alone would not show.

What is exportable: three read shapes plus a reporting-obligation export, which How you read the trail covers.

What the retention is: the global.dalpApp.config.monitoring.auditRetentionYears Helm value takes 5, 7 or 10 years, or indefinite. The default is indefinite, which keeps every row. The window ends at the end of a UTC day. A UTC day past the window is deleted only as a whole, after its chain head has been anchored and after every row of that day is in a published or acknowledged end-of-day audit report. The report reads the trail once, so a row written after that read is in no report and keeps its day. Rows of a platform-wide operation, such as a platform administrator's user administration and impersonation, form one platform chain. The daily job anchors that chain like an organisation's, the platform integrity check reads it, the database refuses a delete of its rows until their day is anchored, and the retention job never removes them, because no end-of-day audit report holds a row without an organisation. The scheduled purge runs after the daily anchor attempt.

Two writes bracket every request

The audit trail writes an entry twice for every covered mutation: once before the handler runs, recording that the attempt happened, and once after, closing the entry with the outcome. A request that dies between the two writes has already left the first one, so no attempt vanishes without a trace. A sensitive read writes both rows after its handler has run.

Both writes join the hash chain of the request's organisation, or the platform chain when the operation is platform-wide. The first write records the request scope, the hash of the previous row, and its own hash before the handler runs. The second write is a new row that records the final outcome and links to the first. The platform's integrity check can therefore detect a changed row, a row removed from the middle of a chain, or a row removed from the end of an anchored day. It still treats an unfinished request as evidence that the attempt began. It cannot detect a removal from a day that has no anchor.

The first write lands before the handler runs. The second appends the outcome.A covered mutation produces two rows. Before the handler runs, a row records the attempt as pending. Then the handler runs, and a second row records success or failure. If the handler never finishes, the first row stays pending, and that stuck row is itself evidence of the attempt.The first write lands before the handler runs. The second appends the outcome.The same figure as the desktop variant, drawn portrait for phone widths. A row records the attempt as pending before the handler runs, the handler runs, and a second row records the outcome. If the handler never finishes, the first row stays pending, and that stuck row is evidence of the attempt.

The first row records the attempt before the handler runs and the second appends the outcome, so a crash between them leaves a pending row as evidence.

How the trail proves that its rows were not changed

Each organisation's rows form a SHA-256 chain in write order, and the rows of platform-wide operations form one more chain. A row hash covers the canonical row fields and the previous row hash, so changing a field breaks that row and removing a row from the middle of the chain breaks the link of the next row. The database sets the previous hash and the hash of every new row, and refuses a row that arrives with either already set. Concurrent requests take turns when they choose the previous hash, which keeps one chain ordered even when several operators act at once.

At 00:05 UTC a daily job writes each organisation's chain head for a closed day into the organisation's document anchor registry on its default network, labelled operator-audit, and stores the transaction hash in a table that only accepts new rows. An organisation with no default network, or no active anchor signer, is retried on the next run, and its days stay unanchored until then. The database also refuses TRUNCATE on the audit table and on the anchor table, and refuses an anchor for a UTC day that has not closed or that is not the last row hash of the day it names. It does not check the stored transaction hash, so an anchor row shows what the database held for that day and is not proof that the head was published on chain. The job anchors an organisation's earliest closed day without an anchor first and stops that organisation at the first day it cannot anchor. It anchors at most 31 days per organisation in one run, so the first run after the feature ships catches up over several days. The retention job runs after this anchor attempt and does not remove an organisation's row from an unanchored day.

The daily job also anchors the platform chain. No organisation owns it, so the job submits its closed-day head through the organisation that anchored first and still has a default network and an active anchor signer. It tries the next organisation when that one cannot sign, and the platform days stay unanchored until one can. The anchor row of a platform day carries no organisation.

An authorised organisation reader can verify a date range of up to 31 days through GET /api/v2/reporting/trail/integrity in the Platform API reference. The check walks the range in batches. An intact response includes the range's head hash. If a row's previous hash or canonical content differs, the response identifies the first broken row and includes the expected and stored hashes so an investigator can compare the database with an exported record or the on-chain anchor. The check also compares the chain head at the end of each anchored day in the range, and the last anchored day before it, with every anchor stored for that day. It reports the reason anchor when a head differs from its anchor, when two anchors of one day disagree, and when an anchored day has no rows left. brokenAnchorId holds the identifier of the anchor involved in an anchor break. When a head differs from its anchor, firstBrokenRowId is the row at the head. When the break is found in the anchors alone, because two anchors disagree or a day has no rows, firstBrokenRowId is empty, and actualHash is empty for a day without rows. A day with no rows left is a break unless it is older than the configured retention window, because retention removes those days. Under the default indefinite retention, every anchored day with no rows is a break. The check cannot tell the removal of a whole day older than the window from retention. It reads only the database and never the on-chain copy, and it cannot detect a change made by a database owner who disables the triggers and then rewrites the chain and its anchors together. The transaction submitted for each day's head is the evidence for that case, and an investigator compares it with the chain.

A platform administrator verifies the platform chain the same way through GET /api/v2/reporting/trail/operator/integrity, which takes the same date range and returns the same answer. That read is itself recorded on the platform chain.

An entry still pending well after its request should have finished is the signal that the completion write failed, not that nothing happened. The trail returns such an entry with the outcome pending and no completion.

The two kinds of covered requests fail differently on purpose when the opening write itself cannot land:

Mutation

Refused outright, before the handler ever runs, so no partial state exists on either side. An unrecorded state change is the exact gap the trail exists to close, so the platform blocks the attempt rather than let it happen invisibly. The refusal costs no availability the mutation had anyway: the trail lives in the same store the mutation was about to write to. The caller gets the platform's standard INTERNAL_SERVER_ERROR. Its reference entry says to retry once and contact support if it persists.

Sensitive read

Served anyway for most reads. The read changes nothing, so refusing it would trade real availability for a weaker claim. The platform logs the audit-write failure for operators to investigate.

Six routes declare that their entry is required, because they hand out a bulk export or a regulated record. When the platform cannot open the entry, it withholds the response and answers with the same error as a refused mutation. They are the audit trail export, the daily transactions report, the shareholder list registrar file, the shareholder list beneficial owners read, the assistant turns export and the trading venue report file read.

Blocking an unrecorded mutation is the rule for every route the request middleware covers. Authentication is the one exception. Signing in with a password or a passkey, signing out, signing up, requesting or completing a password reset, and changing a password all write to the same trail. So do creating, updating or deleting an API key, enabling or disabling an authenticator-app sign-in code, verifying that code or a backup code, and the lock of an account after repeated wrong passwords. Membership writes also get an entry: leaving an organisation, removing a member, sending or cancelling an invitation, changing an organisation's name or settings, deleting an account, and an operator unlocking a locked account. So does a platform administrator's user administration: setting a role, banning and unbanning, creating, updating and removing an account, setting a password, starting and stopping impersonation, and revoking sessions. A password sign-in refused while the lock holds is recorded as a failed sign-in with the code PASSWORD_LOCKED. The recorded authentication writes go through the authentication server's own lifecycle hooks rather than the middleware, because the request middleware does not cover the authentication server's routes, and a sign-in runs before a session exists for it to attach to. A short trail outage must not lock every operator out, so these writes fail open: the attempt still succeeds, and a missed write is logged rather than blocking it. The request waits at most two seconds for its entry. A slower write completes after the response. Some authentication events write no entry, among them a sign-in through an identity provider and a sign-in code sent by email or text message.

One record, written by declaration

One internal record holds every recorded entry: the actor, the role they held at that moment, their tenant, the target, the route, the outcome, and the timing. Request middleware writes it, not the code that handles the request, so coverage follows each route's declared audit disposition rather than anyone's diligence.

Every HTTP API route declares that disposition: mutation, sensitive read, or none. A route with no explicit declaration defaults from its access declaration, so a state-changing route is covered from the moment it is written rather than when someone remembers to instrument it. A coverage guard rejects a write route that opts out without a written exemption.

The entry records the request's target and its JSON body. It stores the route and the resolved path it acted on, plus the query string with credential and personal-data parameters such as passwords, PIN codes, one-time codes, and recovery codes redacted, bounded to 1,024 bytes. Redaction is by parameter name, so an ordinary filter value such as an email can still appear in the query string. Keep personal data out of query parameters.

The platform names the target when it reads the trail, not when it writes the entry. Each entry returned by the trail carries targetName: the token's name, the member's name, the venue's name, the symbol of the token a sale sells, or the name of the venue an add-on access proposal changes. The name is resolved for the caller reading the trail, so a target that caller may not read, one that no longer exists, and a banned member all answer null, and the Console then shows the target's kind label instead. The recorded target stays as it was written, so a rename changes what a later read shows, not what the entry holds.

The entry stores the JSON body as sent, as the request's arguments. Keys that hold credentials, secrets, PIN codes, tokens, keys, or personal data such as a date of birth or an IBAN are replaced by [REDACTED], and a body over 64 KiB is cut to its hash and a bounded diff. Names and email addresses in a body stay readable, so keep personal data out of free-text fields. Routes that carry sealed personal data store a marker instead of the body. A request that fails keeps its arguments. A consolidated transition entry also carries the requested destination state as its reason. A failed transition records the state it was asked to reach, not one it reached. The entry also holds no database link to the organisation, user, or participant it names, the compliance subject recorded for that request, so it outlives all of them.

The entry's participant field names the participant the request resolved to act as. The platform fills it when it records the outcome, from the participant selection the route resolved, so a write made under a delegated authority, the arrangement where one party acts for another, names the party as well as the operator. A request that resolved no participant leaves the field empty. A participant requested with the X-Participant header also appears in the target as participant=<id>. The target is recorded before that selection is checked, so read the participant field, not the target, for whom the actor acted for. When a platform administrator impersonates a user, the entry also records the administrator who started that session, and the trail shows them as the operator acting as the user.

How you read the trail

The shape of your question decides which read to use. For self-service, the Console's audit trail page shows an organisation's own operations. It opens on the last 30 days, and the reader can choose another window and page through all of it. It is gated no tighter than the API behind it: the organisation's owner, a platform administrator, or a holder of the Permission manager, Auditor, Compliance manager or System manager system role may open it. An Auditor who cannot approve anything can still see what happened.

Each entry's time is the platform server's clock at the moment it handled the request, not a block time. On a network whose clock runs ahead of real time, an entry can read earlier than the chain activity it caused. On an idle network, it can read later than the newest block. Chain-dated figures follow the rule in Ledger data.

The API answers three other shapes of question, detailed in full in the API reference. It covers a time window over your own tenant's trail, one entry by its id, and every entry that touched a given resource across every actor and route. That third shape is usually where an investigation starts, because the question is “what happened to this thing”, not “what did this person do”. A fourth shape, gated to platform administrators, spans every tenant plus the entries that belong to no tenant at all.

The window reads return 200 entries per call at most, newest first. The response's total count is the number of entries the whole window holds, and each window read gives a cursor for the next page, so a caller can walk to the oldest entry. The resource read has no cursor, so it returns the newest 200 matches, and a total above 200 shows that more exist. The lookup of one entry by its id finds the entry wherever it sits in the window. For a window too large to walk page by page, the CSV export returns every entry of a window in one streamed file. The trail is also one of the platform's own report kinds. A reporting obligation can carry it in its report set, and that export reads the same table without the row limit.

Two safety details matter for an integrator. Reading the trail is itself a sensitive read and gets its own entry, so the trail records its own readers. And a lookup of another tenant's entry returns not found rather than forbidden, because a forbidden response would confirm the identifier exists, which is itself a disclosure across tenants. A not-found on your own tenant's entry can also mean the window missed it, so narrow the window before assuming the id is wrong.

What you can show a regulator

From the Console you can show a regulator your organisation's operations: the actor, their platform and organisation role at that moment, the route and the outcome. Each entry also records the participant the request acted as. For the time before your deployment began writing the trail, you can show nothing.

That limit follows from the write path already described. The evidence for who a delegated actor was acting for lives elsewhere: the ledger names the party's account as the sender of the transaction, and the organisation's system access manager names who could act for it at the time. Delegated Authority covers both.

Do not tell a regulator that a database owner cannot change the store: the hash chain and its on-chain anchor are the evidence that exposes a change. Do not promise a fixed retention period across deployments either: the window is configured, while anchoring and the end-of-day audit reports decide when an expired row may be removed.

The Console gives an authorised reader the trail without an engineer. The screen opens on the last 30 days, and its Export downloads every entry of the window and filters the reader picks. A control that needs the trail filed on a schedule should get it from a reporting obligation's export, not from a single API page.

Frequently asked questions

Does an audit entry show what a request changed?

Where the route records it, yes. A route that records a change stores the old and new values on the entry, and the Console shows them as Before and After. Configuration changes are: a wallet checker, a custody provider configuration, an obligation, the statements policy, platform settings, the theme, holiday calendars, templates and profile sections, and the network gas payer. A platform administrator's user administration records old and new values the same way: the role, the ban, a created, updated or removed account, and the count of a user's active sessions on a revoke. An update to an API key's settings by its holder does too. During an impersonation the entry also names the administrator. A password an administrator sets is recorded only as replaced, and an impersonation names the user it started on. These entries belong to no organisation, so an organisation's Audit trail page does not list them. The same holds for every write that reaches all organisations: the indexing scope, networks and RPC pools, base calendars, the platform assistant switch, the global theme, an organisation's archive and restore, and an administrator's onboarding resets. A platform administrator reads them through the platform-wide query. The entry holds the old and new value of each field that changed, and the reason the operator gave on the forms that ask for one. A change that leaves the stored value as it was records nothing, and credentials and API keys are never recorded. One record, written by declaration describes how the entry stores the request body.

On-chain access control and smart wallet writes record that view when the platform accepts the request. This covers role changes on the system, tokens, custody gateways and trading venues, add-on proposals, and smart wallet signers, thresholds and modules. The before value is the indexed state read just before the write, with the indexed block it reflects. The after value is the state the request asks for. A queued write records the same view as one that settles inside the request.

Compliance, token control and trust registry writes record it too. This covers compliance module installs, removals, scopes, transitions and parameters on a token and on the system, the compliance expression, holder locks, the supply cap, pause and unpause, metadata, collateral, price, yield schedules, trusted issuers and their claim topics, claim topics and topic schemes. A creation records an empty before value, and a removal records the old value and no after value.

Token feature and price feed writes record it as well, in raw units. This covers attaching, rotating, detaching and updating a feature, feature transitions, fee rate freezes, fee exemptions, distribution netting, hold reservers, supply delegates, authorised converters, conversion triggers, accrual exclusions, register line settings, fixed yield schedules, and price feed registration, replacement, removal, adapters, issuer-signed feeds and staleness windows, and the custody gateway token, profile and gateway settings. An update records only the fields the request sets, and a list change records the whole list before and after. A price feed write runs as a workflow, so the platform links its transaction when the workflow ends, whether it succeeded or failed. A failed feed write keeps its values and its linked transaction, and reads as reverted. A feed request repeated under the same idempotency key records no change. Mapping instruments to a price source records the requested instruments and the operation id with no transaction link, because the outcome per instrument is read from the operation. Revoke routes and treasury allowance approvals record no change.

Token sale, refund splitter, paymaster, external token and factory writes record it too. For a token sale this covers setting the terms, extending the sale, changing its status, the presale whitelist, the purchase increment, investor allocations, application mode, the greenshoe and lock-ups. A status change to Public sale, Paused or Ended records that status. An activation or a finalisation records the requested transition instead, because the contract decides which status the sale lands in. An extension records the new end time, and a whitelist change records each named investor as listed or not. An investor whose listing does not change is left out. Application mode, the greenshoe and lock-ups have no indexed state, so their before value is read from the sale's contract and carries no indexed block. A lock-up change records the categories with their unlock steps and the default steps, before and after. A sale role set records the role holders before and after through the token it belongs to. The refund splitter records its warning and critical thresholds, or its refund share. A paymaster signer key rotation records the signer address before and after, and never key material. Registering an external token records its address and type, promoting it records its type, origin and whether it is dismissed, and dismissing it records the dismissed flag. Creating token or add-on factories records an empty before value and the factories the request creates, and a request for factories that already exist records nothing.

Custody gateway, bank rail and organisation network writes record it as well. For a custody gateway this covers its profile, client wallet, approved sources and payouts, funding sources, auto-claim, default payout and pause, unpause and close, and the creation of a coin, profile or gateway. For a bank rail it covers the escrow, the activation of a token link, and a link's mint cap and credit threshold with the approval request they open or close. Adding a network to an organisation records an empty before value and the requested network, or the earlier status of a failed or pending network that is retried, and it has no transaction link because it starts a workflow instead of queueing one transaction.

Holder locks are read from the platform's index of lock events. If the index holds no lock for the holder, the before value reads none indexed with the indexed block, and indexRebuilding: true is added while the index is rebuilding. none indexed does not mean the holder had no lock: the chain's HolderLockSet events settle that, and the entry keeps the wording it was written with. POST /system/compliance-modules records before and after but has no transaction link, because it starts a workflow instead of queueing one transaction.

An accepted request is not an applied change. The entry links the transactions the request queued and shows one outcome: queued while any is open, confirmed when all completed, reverted when any did not take effect. A role grant whose transaction reverts reads as reverted, not as a success.

Are denied and failed requests recorded?

Yes. The opening write lands before the handler runs, so an attempt is recorded whether it later succeeds, fails, or is refused, and the closing write stores the HTTP status the caller received. A denial or a rejected submission never reaches the chain at all, which is exactly why the off-chain trail records it. A caller with no session is recorded too, with the actor marked as anonymous.

How do you tie an audit entry to logs and the transaction it produced?

Through the request identifier. Every entry stores the id of the request that produced it, and the same identifier appears in the platform's structured logs and traces for that call. For a write that reached the chain, it is also the join key back to the resulting transaction, so one identifier connects the trail entry, the logs, and the on-chain effect.

What happens to an entry when the account it names is deleted?

Nothing happens to it. The entry holds no database link to the account it names, so deleting the account does not cascade into the entry, and the evidence outlives the account.

Was this page helpful?