> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# The audit ledger model

> How Devotel Orbit's audit trail proves completeness: a per-organization hash chain over canonicalized events, daily Merkle roots anchored to external storage, and the three planes — write, stream, and export/verify — that move the ledger without weakening it.

# The audit ledger model

Every state-changing action in Devotel Orbit — creating an API key, changing a
role, updating a flow, exporting a contact list — is recorded in an audit
ledger that is built to answer one question an auditor actually asks: *is this
the complete, unaltered history?* A plain append-only table cannot answer it,
because a deleted or edited row is indistinguishable from one that was never
written. Orbit's ledger answers it with a **hash chain** over every row, a
**daily Merkle root** that anchors the chain to storage outside the database,
and **per-row canonicalization versioning** so verification replays exactly
what was written.

This page explains the model: what the chain proves and what it deliberately
does not, why the three planes that move audit data exist, and how to read a
verification verdict. For the operational workflow (queueing an export,
polling it, running the verifier), see
[Audit log export and verification](/compliance/audit-export); for the
dashboard surface and quick CSV/JSON exports, see the
[Audit Log guide](/guides/audit-log). SIEM streaming lives on the tenant
webhook fabric — subscribe an endpoint to `audit.log.created` in
**Settings → Webhooks**.

## The three planes

Audit data moves through three deliberately decoupled planes. Only the first
one can touch the chain; the other two are readers.

**Write plane — one append path.** Every API route, scheduler, and background
worker records audit events through a single append function. All 600+ call
sites funnel into one chokepoint that resolves the event, opens a transaction,
takes a cluster-wide PostgreSQL advisory lock scoped to your organization,
reads the chain tail, computes the new hash, and inserts the row — all inside
one commit. A single chokepoint is what makes the chain meaningful: there is
no second write path that could append a row without linking it. Concurrency
within one API replica is additionally queued in-process, and across replicas
the advisory lock serializes — so the chain advances one append at a time per
organization even under bulk fan-out.

**Streaming plane — push after commit.** Once the write plane's transaction
has committed, the handler fires an `audit.log.created` event onto your
tenant's webhook fabric if you have an endpoint subscribed to it and platform
streaming is enabled for your workspace. The event carries the row's hash
(`current_hash`) so your SIEM can later cross-check the streamed record
against an export's verification verdict. Delivery is fire-and-forget: the
stream is never awaited, so a slow or failing SIEM can stall neither the API
response nor the hash chain. An optional filter
(`security`, or specific category/action prefixes) narrows the stream to the
security-relevant slice for SOC teams that do not want the full firehose.

**Export and verify plane — queued bundles, replayable proofs.** When a
reviewer needs a bounded slice — a quarter, an incident window — an export job
bundles your ledger rows into a downloadable file, and the chain-verify
endpoint replays the hash chain over the same day range and reports
`chain_valid:true|false` with the daily Merkle roots for those days. The
export is queued rather than synchronous so a range covering hundreds of
thousands of events never blocks your request.

## The hash chain

Every ledger row stores two digests: `prev_hash`, the `current_hash` of the
previous row in your organization's chain, and `current_hash`, the SHA-256 of
`prev_hash` concatenated with the row's canonicalized content. The first row
an organization ever writes anchors from a fixed 64-zero genesis sentinel
rather than a NULL, so verification is a pure function with no special-case
branch on the head row.

A worked three-event chain:

| Row                 | `prev_hash` | `current_hash` (illustrative) |
| ------------------- | ----------- | ----------------------------- |
| `event_a` (genesis) | `0000…0000` | `9a4fe2…`                     |
| `event_b`           | `9a4fe2…`   | `71c38d…`                     |
| `event_c`           | `71c38d…`   | `b20e91…`                     |

Because each row embeds its predecessor's digest, a broken link is provable:
if a row is inserted, removed, reordered, or edited, the `prev_hash` pointers
stop matching, or a recomputed `current_hash` stops matching the stored one.
Verification walks the rows in original append order (`created_at`, then id)
and flags three defect classes:

* `missing_current_hash` — the row was written outside the chained path;
* `prev_hash_mismatch` — the link to the predecessor is broken (insert /
  remove / reorder);
* `current_hash_mismatch` — the row's content changed after it was hashed.

The verifier compares the recomputed digest against the stored one and reports
independent verification — the export's rows are checked on the server, and
the same algorithm is simple enough to replay offline if your auditor prefers.

## Canonicalization versions

The hash is computed over a **canonical string** — one fixed serialization of
the event fields — so that two different services hashing the same event
produce bit-identical input. Which serialization is correct has changed once:

* **v1 (legacy)** sorted only top-level keys. Nested objects were emitted in
  insertion order, so two code paths building the same nested `details` object
  could silently produce different digests.
* **v2 (current)** sorts keys recursively at every depth; array element order
  is preserved, because ordering is semantically meaningful in audit payloads.

Each row stores which version produced its hash (`canonicalization_version`,
`1` or `2`). That persistence is the point: the verifier re-canonicalizes
every historical row with the algorithm that hashed it at write time, so the
chain keeps verifying across the algorithm upgrade with no rewrite of stored
rows. Rows written before the version column existed are read as v1.

A v2 canonicalization example — this nested `details` payload:

```json theme={null}
{
  "resource": "api_key",
  "details": { "scope": ["read"], "label": "ci" }
}
```

is serialized with keys sorted at every depth before hashing:

```text theme={null}
{"details":{"label":"ci","scope":["read"]},"resource":"api_key"}
```

and the hash input is `prev_hash` concatenated with that exact string.

## Daily Merkle roots

A scheduler rolls each UTC day's chain rows into one **Merkle root** — a
balanced binary tree over that day's `current_hash` leaf values — and persists
it on the daily-roots table with the row count and the first and last event
ids. The root blob is then uploaded to immutable (WORM) object storage and the
signed anchor URL is recorded on the row.

One root per UTC day does three things for an auditor:

* **Compresses the day.** Instead of re-hashing thousands of rows to confirm a
  day, a reviewer re-derives one 64-hex digest from the day's leaves and
  compares it to the anchored root.
* **Anchors externally.** Because the root digest is stored outside the
  database, a post-hoc mutation visible in the database cannot be laundered
  into a "new" legitimate root — recomputation refuses to overwrite an already
  computed day.
* **Bounds the export contract.** Exports require day-aligned `from`/`to`
  dates because a partial day would anchor against an incomplete root. The
  verify endpoint returns the stored roots alongside the chain verdict so the
  reviewer can also confirm each day's `row_count` against the exported row
  count.

An empty day is not an anomaly: the verify summary treats a range with no
roots as fully anchored — you cannot be behind on anchoring rows that do not
exist.

## Tenant resolution, hashed vs non-hashed

Two resolver behaviours explain what you see on the dashboard and why edits to
some columns do not corrupt the chain:

* **Organization → tenant resolution.** Call sites historically recorded only
  the organization id; the write plane back-fills the platform tenant id from
  a cached org→tenant lookup so per-tenant operator queries (and per-tenant
  SIEM streaming) actually see the row. Well-known sentinel scopes such as the
  public-DSAR path are remapped to the platform system organization before the
  insert, so no audit row ever carries a non-organization placeholder.
* **What is hashed.** Exactly eleven fields form the hash input: id,
  organization, tenant, user, action, resource, resource id, details, IP
  address, user agent, and the timestamp. The daily-root anchor URL and other
  bookkeeping columns are deliberately **outside** the hashed shape, so the
  scheduler can attach the signed anchor URL after the fact without altering a
  row's proof. Everything that answers "who did what, on which resource, when"
  is inside the hash.

## Failure behavior

The ledger is best-effort by design, and the failure semantics are the same
across every surface:

* **Writes fail open.** Non-blocking call sites log safe identifiers —
  organization, user, action, resource, and a short digest fingerprint of the
  payload (never the payload itself, which can carry PII) — and emit a metric
  so a sustained failure rate pages without ever exposing event contents.
* **Reads fail closed.** Verification validates the shape of every row it
  replays; a schema or SELECT drift that reads as a missing column throws a
  loud error rather than silently flipping a healthy chain to "invalid".
* **Streaming never blocks.** SIEM dispatch happens after the append commits
  and is never awaited; faults are logged at warning level and surfaced
  nowhere in the request path.
* **Genuine faults page.** Transient database-availability blips (connection
  pooler timeouts, statement timeouts) are demoted to metrics-only, but a real
  chain or constraint fault still raises an alert — a broken audit chain never
  goes unnoticed.

## Further reading

* [Audit log export, chain verification, and refresh workflow](/compliance/audit-export) — queue a bounded export, poll it to a signed URL, and replay the chain over your date range.
* [Audit Log guide](/guides/audit-log) — the Settings → Audit log dashboard surface, filters, and quick CSV/JSON exports.
* [SIEM sinks](/compliance/siem-sinks) — continuous push delivery to Splunk, Datadog, or a drain you own, as an alternative to `audit.log.created` webhooks.
* [Tenant-isolation model](/concepts/tenant-isolation) — the org/tenant scoping rules the ledger's resolver builds on.
