Skip to main content

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; for the dashboard surface and quick CSV/JSON exports, see the Audit Log guide. 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: 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:
is serialized with keys sorted at every depth before hashing:
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